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 60 vi.stubGlobal("fetch", vi.fn(async () => {61 throw new Error("network disabled in tests");62 }));63 });6465 it("creates a vault and stores encrypted payload", async () => {66 const vault = await import("./vault");6768 await vault.createVault("correct horse battery staple");6970 expect(storage.peach_vault_a).toBeDefined();71 expect((storage.peach_vault_a as StoredVault).ciphertext).toBeTruthy();72 expect(vault.getEntries()).toEqual([]);73 expect(alarms.created).toContainEqual({74 name: "autolock",75 delayInMinutes: 5,76 });77 });7879 it("writes both slots A and B on vault creation", async () => {80 const vault = await import("./vault");81 await vault.createVault("correct horse battery staple");8283 expect(storage.peach_vault_a).toBeDefined();84 expect(storage.peach_vault_b).toBeDefined();85 expect(storage.peach_duress).toBeUndefined();86 expect(storage.peach_vault).toBeUndefined();87 expect(storage.peach_decoy_vault).toBeUndefined();88 });8990 it("slot A and slot B have matching ciphertext size at fresh install", async () => {91 const vault = await import("./vault");92 await vault.createVault("correct horse battery staple");9394 const a = storage.peach_vault_a as StoredVault;95 const b = storage.peach_vault_b as StoredVault;96 expect(a.ciphertext.length).toBe(b.ciphertext.length);97 });9899 it("does not expose a plaintext duress flag after setupDuress", async () => {100 const vault = await import("./vault");101 await vault.createVault("real-password-12345");102 await vault.setupDuress("decoy-password-67890");103104 expect(storage.peach_duress).toBeUndefined();105 expect(storage.peach_decoy_vault).toBeUndefined();106 const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));107 108 109 for (const key of keys) {110 expect(111 [112 "peach_vault_a",113 "peach_vault_b",114 "peach_settings",115 "peach_vault_history",116 "peach_last_modified_at",117 ]118 ).toContain(key);119 }120 expect(keys).toContain("peach_vault_a");121 expect(keys).toContain("peach_vault_b");122 });123124 it("unlocks with real password and loads real entries", async () => {125 const vault = await import("./vault");126 await vault.createVault("real-password-12345");127 await vault.addEntry({128 title: "GitHub",129 username: "june",130 password: "realsecret",131 url: "github.com",132 });133 vault.lock();134135 const ok = await vault.unlock("real-password-12345");136 expect(ok).toBe(true);137 expect(vault.getEntries()).toHaveLength(1);138 expect(vault.getEntries()[0].title).toBe("GitHub");139 });140141 it("unlocks with duress password and loads empty entries", async () => {142 const vault = await import("./vault");143 await vault.createVault("real-password-12345");144 await vault.addEntry({145 title: "GitHub",146 username: "june",147 password: "realsecret",148 url: "github.com",149 });150 await vault.setupDuress("decoy-password-67890");151 vault.lock();152153 const ok = await vault.unlock("decoy-password-67890");154 expect(ok).toBe(true);155 expect(vault.getEntries()).toEqual([]);156 });157158 it("HOLE 5: the duress vault has no sync identity and cannot sync", async () => {159 const vault = await import("./vault");160 await vault.createVault("real-password-12345");161 await vault.addEntry({ type: "login", title: "GitHub", password: "realsecret" });162 await vault.setupDuress("decoy-password-67890");163164 165 expect(vault.getRootKeyHex()).toBeTruthy();166 vault.lock();167168 expect(await vault.unlock("decoy-password-67890")).toBe(true);169 170 171 172 expect(vault.getRootKeyHex()).toBeNull();173174 175 176 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");177 const adapter = createSyncVaultAdapter();178 await expect(adapter.computeVaultId()).rejects.toThrow();179 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toThrow();180 vault.lock();181 });182183 it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {184 185 186 187 188 const argon2 = await import("../crypto/argon2");189 const deriveSpy = vi.spyOn(argon2, "deriveKey");190191 const vault = await import("./vault");192 await vault.createVault("real-password-12345");193 await vault.setupDuress("decoy-password-67890");194 vault.lock();195196 deriveSpy.mockClear();197 await vault.unlock("real-password-12345");198 expect(deriveSpy).toHaveBeenCalledTimes(2);199 vault.lock();200201 deriveSpy.mockClear();202 await vault.unlock("decoy-password-67890");203 expect(deriveSpy).toHaveBeenCalledTimes(2);204 vault.lock();205206 deriveSpy.mockClear();207 await vault.unlock("wrong-password-xxxxx");208 expect(deriveSpy).toHaveBeenCalledTimes(2);209210 deriveSpy.mockRestore();211 });212213 it("rejects wrong passwords", async () => {214 const vault = await import("./vault");215 await vault.createVault("real-password-12345");216 vault.lock();217218 const ok = await vault.unlock("nope-nope-nope");219 expect(ok).toBe(false);220 });221222 it("persists duressConfigured across lock/unlock in encrypted payload", async () => {223 const vault = await import("./vault");224 await vault.createVault("real-password-12345");225 await vault.setupDuress("decoy-password-67890");226 vault.lock();227228 await vault.unlock("real-password-12345");229 expect(await vault.isDuressEnabled()).toBe(true);230231 await vault.disableDuress();232 expect(await vault.isDuressEnabled()).toBe(false);233 vault.lock();234235 await vault.unlock("real-password-12345");236 expect(await vault.isDuressEnabled()).toBe(false);237 });238239 it("migrates legacy peach_vault / peach_decoy_vault / peach_duress schema", async () => {240 241 const firstVault = await import("./vault");242 await firstVault.createVault("legacy-password-abcde");243 const legacySlotA = storage.peach_vault_a as StoredVault;244 storage.peach_vault = legacySlotA;245 delete storage.peach_vault_a;246 delete storage.peach_vault_b;247 storage.peach_duress = { enabled: true };248 storage.peach_decoy_vault = legacySlotA; 249 vi.resetModules();250251 252 const vault = await import("./vault");253 await vault.hasVault();254255 256 expect(storage.peach_vault).toBeUndefined();257 expect(storage.peach_decoy_vault).toBeUndefined();258 expect(storage.peach_duress).toBeUndefined();259 expect(storage.peach_vault_a).toBeDefined();260 expect(storage.peach_vault_b).toBeDefined();261 });262263 it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {264 const vault = await import("./vault");265266 await vault.createVault("correct horse battery staple");267 const entry = await vault.addEntry({268 title: "GitHub",269 username: "june",270 password: "secret123",271 url: "github.com",272 });273274 await vault.deleteEntry(entry.id);275276 expect(vault.getEntries()).toEqual([]);277278 const stored = storage.peach_vault_a as StoredVault;279 const salt = Uint8Array.from(280 stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))281 );282 const key = await deriveKey("correct horse battery staple", salt);283 const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");284 const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };285286 expect(parsed.entries).toHaveLength(1);287 expect(parsed.entries[0].id).toBe(entry.id);288 expect(typeof parsed.entries[0].deletedAt).toBe("number");289 });290291 it("accepts imported entries without a username or url", async () => {292 const vault = await import("./vault");293294 await vault.createVault("correct horse battery staple");295 const entry = await vault.addEntry({296 title: "Imported Login",297 username: "",298 password: "secret123",299 url: "",300 notes: "migrated from another manager",301 });302303 expect(entry.username).toBe("");304 expect(entry.url).toBe("");305 expect(vault.getEntries()).toHaveLength(1);306 });307308 it("changePassword happy path — new password unlocks, old is rejected", async () => {309 const vault = await import("./vault");310311 await vault.createVault("old-pass-abc123");312 await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });313 await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });314315 expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);316317 vault.lock();318 expect(await vault.unlock("new-pass-xyz789")).toBe(true);319 expect(vault.getEntries()).toHaveLength(2);320321 vault.lock();322 expect(await vault.unlock("old-pass-abc123")).toBe(false);323 });324325 it("stamps peach_last_modified_at on createVault", async () => {326 const vault = await import("./vault");327 const before = Date.now();328 await new Promise((resolve) => setTimeout(resolve, 1));329330 await vault.createVault("stamp-create-pass");331332 expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);333 });334335 it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {336 const vault = await import("./vault");337 await vault.createVault("stamp-entry-pass");338339 let previous = Number(storage.peach_last_modified_at);340 await new Promise((resolve) => setTimeout(resolve, 1));341 const entry = await vault.addEntry({342 title: "Stamp",343 username: "",344 password: "p",345 url: "",346 });347 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);348349 previous = Number(storage.peach_last_modified_at);350 await new Promise((resolve) => setTimeout(resolve, 1));351 await vault.updateEntry(entry.id, { title: "Stamped" });352 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);353354 previous = Number(storage.peach_last_modified_at);355 await new Promise((resolve) => setTimeout(resolve, 1));356 await vault.deleteEntry(entry.id);357 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);358 });359360 it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {361 const vault = await import("./vault");362 await vault.createVault("stamp-real-pass");363364 let previous = Number(storage.peach_last_modified_at);365 await new Promise((resolve) => setTimeout(resolve, 1));366 await vault.setupDuress("stamp-decoy-pass");367 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);368369 previous = Number(storage.peach_last_modified_at);370 await new Promise((resolve) => setTimeout(resolve, 1));371 expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);372 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);373 });374375 it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {376 const vault = await import("./vault");377378 await vault.createVault("correct-pass-abcde");379 const slotABefore = JSON.stringify(storage.peach_vault_a);380381 expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);382 expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);383384 vault.lock();385 expect(await vault.unlock("correct-pass-abcde")).toBe(true);386 });387388 it("changePassword with duress configured leaves slot B byte-identical", async () => {389 const vault = await import("./vault");390391 await vault.createVault("real-pass-abcde");392 await vault.setupDuress("duress-pass-xyzzz");393394 const slotBBefore = JSON.stringify(storage.peach_vault_b);395 expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);396 expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);397398 399 vault.lock();400 expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);401 expect(vault.getEntries()).toEqual([]);402 });403404 it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {405 const vault = await import("./vault");406407 await vault.createVault("pass-abcdef");408 await vault.addEntry({ title: "One", username: "", password: "1", url: "" });409 await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });410 await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });411412 await expect(vault.restoreEntries([], "replace")).rejects.toThrow(413 /refuse to replace with empty entry set/i414 );415 expect(vault.getEntries()).toHaveLength(3);416 });417418 it("changePassword swaps the active key before Slot B is regenerated", async () => {419 const vault = await import("./vault");420421 await vault.createVault("correct-pass-12345");422 const entry = await vault.addEntry({423 title: "GitHub",424 username: "june",425 password: "secret123",426 url: "https://github.com",427 });428429 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;430 const originalSet = setMock.getMockImplementation();431 let injectedDuringSlotBWrite = false;432433 setMock.mockImplementation(async (next: Record<string, unknown>) => {434 Object.assign(storage, next);435436 if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {437 injectedDuringSlotBWrite = true;438 await vault.updateEntry(entry.id, { notes: "updated during password change" });439 }440 });441442 expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);443444 setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {445 Object.assign(storage, next);446 }));447448 vault.lock();449 expect(await vault.unlock("new-pass-67890")).toBe(true);450 expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");451452 vault.lock();453 expect(await vault.unlock("correct-pass-12345")).toBe(false);454 });455456 it("restoreEntries replace mode replaces existing entries", async () => {457 const vault = await import("./vault");458459 await vault.createVault("replace-pass-12345");460 await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });461 await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });462463 const incoming = [464 {465 id: "entry-c",466 type: "login" as const,467 title: "C",468 username: "june",469 password: "three",470 url: "https://c.test",471 createdAt: 10,472 modifiedAt: 10,473 },474 {475 id: "entry-d",476 type: "login" as const,477 title: "D",478 username: "june",479 password: "four",480 url: "https://d.test",481 createdAt: 20,482 modifiedAt: 20,483 },484 ];485486 await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({487 added: 2,488 updated: 0,489 skipped: 0,490 });491492 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);493 });494495 it("restoreEntries merge mode lets newer incoming entries win", async () => {496 const vault = await import("./vault");497498 await vault.createVault("merge-pass-12345");499 const existing = await vault.addEntry({500 title: "GitHub",501 username: "june",502 password: "old-secret",503 url: "https://github.com",504 });505506 await expect(507 vault.restoreEntries(508 [509 {510 ...existing,511 password: "new-secret",512 modifiedAt: existing.modifiedAt + 100,513 },514 ],515 "merge"516 )517 ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });518519 expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");520 });521522 it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {523 const vault = await import("./vault");524525 await vault.createVault("merge-pass-67890");526 const existing = await vault.addEntry({527 title: "GitHub",528 username: "june",529 password: "current-secret",530 url: "https://github.com",531 });532533 await expect(534 vault.restoreEntries(535 [536 {537 ...existing,538 password: "stale-secret",539 modifiedAt: existing.modifiedAt - 100,540 },541 ],542 "merge"543 )544 ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });545546 expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");547 });548549 it("clearVaultEntries without explicit confirmation throws", async () => {550 const vault = await import("./vault");551552 await vault.createVault("clear-pass-12345");553 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });554555 const clearVaultEntries = vault.clearVaultEntries as unknown as (556 confirmation?: unknown557 ) => Promise<void>;558559 await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);560 await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);561 expect(vault.getEntries()).toHaveLength(1);562 });563564 it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {565 const vault = await import("./vault");566567 await vault.createVault("clear-pass-67890");568 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });569 await vault.addEntry({ title: "B", username: "", password: "two", url: "" });570 await vault.addEntry({ title: "C", username: "", password: "three", url: "" });571572 await vault.clearVaultEntries("REPLACE_ALL");573574 expect(vault.getEntries()).toEqual([]);575 expect(vault.getDeletedEntries()).toHaveLength(3);576 expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);577 });578579 it("deduplicateEntries throws in decoy mode", async () => {580 const vault = await import("./vault");581582 await vault.createVault("real-pass-12345");583 await vault.addEntry({584 title: "GitHub",585 username: "june",586 password: "secret123",587 url: "https://github.com",588 });589 await vault.setupDuress("duress-pass-12345");590 vault.lock();591592 expect(await vault.unlock("duress-pass-12345")).toBe(true);593 await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);594 });595596 it("deduplicateEntries removes older duplicates in real mode", async () => {597 const vault = await import("./vault");598599 await vault.createVault("dedupe-pass-12345");600 await vault.addEntry({601 title: "GitHub",602 username: "june",603 password: "old-secret",604 url: "https://github.com",605 });606 await new Promise<void>((resolve) => setTimeout(resolve, 5));607 const newer = await vault.addEntry({608 title: "GitHub",609 username: "june",610 password: "new-secret",611 url: "https://github.com",612 });613614 expect(await vault.deduplicateEntries()).toBe(1);615 expect(vault.getEntries()).toHaveLength(1);616 expect(vault.getEntries()[0].id).toBe(newer.id);617 expect(vault.getDeletedEntries()).toHaveLength(1);618 });619620 it("updateEntry with an empty password on a login entry clears the password", async () => {621 const vault = await import("./vault");622623 await vault.createVault("update-pass-12345");624 const entry = await vault.addEntry({625 title: "GitHub",626 username: "june",627 password: "secret123",628 url: "https://github.com",629 });630631 await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();632 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();633 });634635 it("updateEntry with an empty password on a note entry is allowed", async () => {636 const vault = await import("./vault");637638 await vault.createVault("note-pass-12345");639 const entry = await vault.addEntry({640 type: "note",641 title: "Secure note",642 notes: "keep me",643 });644645 await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();646 expect(vault.getEntryById(entry.id)).toMatchObject({647 type: "note",648 notes: "updated",649 });650 });651652 it("lock zeroes the derived key and resets unlock state", async () => {653 const aes = await import("./aes");654 const zeroKeySpy = vi.spyOn(aes, "zeroKey");655 const vault = await import("./vault");656657 await vault.createVault("lock-pass-12345");658 expect(vault.isUnlocked()).toBe(true);659660 vault.lock();661662 expect(zeroKeySpy).toHaveBeenCalled();663 expect(vault.isUnlocked()).toBe(false);664 zeroKeySpy.mockRestore();665 });666667 it("lock clears in-memory entries", async () => {668 const vault = await import("./vault");669670 await vault.createVault("lock-pass-67890");671 const entry = await vault.addEntry({672 title: "GitHub",673 username: "june",674 password: "secret123",675 url: "https://github.com",676 });677678 expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");679 vault.lock();680 expect(vault.getEntryById(entry.id)).toBeUndefined();681 });682683 it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {684 const vault = await import("./vault");685686 await vault.createVault("real-pass-67890");687 vault.lock();688689 expect(await vault.unlock("totally-wrong-password")).toBe(false);690 expect(vault.isUnlocked()).toBe(false);691 });692693 it("isUnlocked tracks the full lock lifecycle", async () => {694 const vault = await import("./vault");695696 expect(vault.isUnlocked()).toBe(false);697 await vault.createVault("unlock-pass-12345");698 expect(vault.isUnlocked()).toBe(true);699 vault.lock();700 expect(vault.isUnlocked()).toBe(false);701 expect(await vault.unlock("unlock-pass-12345")).toBe(true);702 expect(vault.isUnlocked()).toBe(true);703 vault.lock();704 expect(vault.isUnlocked()).toBe(false);705 });706707 it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {708 const vault = await import("./vault");709710 await vault.createVault("quota-pass-12345");711712 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;713 setMock.mockImplementation(async (next: Record<string, unknown>) => {714 if ("peach_vault_a" in next || "peach_vault_b" in next) {715 throw new Error("QUOTA_BYTES quota reached");716 }717 Object.assign(storage, next);718 });719720 await expect(721 vault.addEntry({722 title: "GitHub",723 username: "june",724 password: "secret123",725 url: "https://github.com",726 })727 ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");728 });729730 it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {731 const vault = await import("./vault");732733 await vault.createVault("settings-pass-12345");734735 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;736 setMock.mockImplementation(async (next: Record<string, unknown>) => {737 if ("peach_settings" in next) {738 throw new Error("QUOTA_BYTES quota reached");739 }740 Object.assign(storage, next);741 });742743 await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(744 "STORAGE_QUOTA_EXCEEDED"745 );746 });747748 it("card entry round-trips through vault — all fields preserved", async () => {749 const vault = await import("./vault");750751 await vault.createVault("card-test-pass-12345");752 const entry = await vault.addEntry({753 type: "card",754 title: "Personal Visa",755 cardholderName: "Jane Smith",756 cardNumber: "4111111111111111",757 expMonth: "05",758 expYear: "2028",759 cvc: "123",760 cardNickname: "Personal Visa",761 network: "visa",762 last4: "1111",763 });764765 expect(entry.type).toBe("card");766 expect(entry.cardholderName).toBe("Jane Smith");767 expect(entry.cardNumber).toBe("4111111111111111");768 expect(entry.expMonth).toBe("05");769 expect(entry.expYear).toBe("2028");770 expect(entry.cvc).toBe("123");771 expect(entry.network).toBe("visa");772 expect(entry.last4).toBe("1111");773774 vault.lock();775 await vault.unlock("card-test-pass-12345");776 const entries = vault.getEntries();777 expect(entries).toHaveLength(1);778 expect(entries[0].cardNumber).toBe("4111111111111111");779 expect(entries[0].cvc).toBe("123");780 });781782 it("updateEntry — cannot clear card number on card entry", async () => {783 const vault = await import("./vault");784785 await vault.createVault("card-guard-pass-12345");786 const entry = await vault.addEntry({787 type: "card",788 title: "My Card",789 cardholderName: "Jane Smith",790 cardNumber: "4111111111111111",791 expMonth: "05",792 expYear: "2028",793 cvc: "123",794 });795796 await expect(797 vault.updateEntry(entry.id, { cardNumber: "" })798 ).rejects.toThrow("card number is required for card entries");799800 const entries = vault.getEntries();801 expect(entries[0].cardNumber).toBe("4111111111111111");802 });803804 it("declineCardSaveOffer — stores fingerprint in settings", async () => {805 const vault = await import("./vault");806807 await vault.createVault("decline-pass-12345");808 await vault.declineCardSaveOffer("1111", "05", "2028");809810 const settings = vault.getSettings();811 expect(settings.declinedCardFingerprints).toHaveLength(1);812 });813814 it("isCardDeclined — returns true for previously declined card", async () => {815 const vault = await import("./vault");816817 await vault.createVault("declined-check-pass-12345");818 await vault.declineCardSaveOffer("1111", "05", "2028");819820 expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);821 });822823 it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {824 const vault = await import("./vault");825826 await vault.createVault("declined-diff-pass-12345");827 await vault.declineCardSaveOffer("1111", "05", "2028");828829 expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);830 });831832 it("queryDomain matches an entry against its additionalUrls", async () => {833 const vault = await import("./vault");834835 await vault.createVault("multi-domain-pass-12345");836 const entry = await vault.addEntry({837 type: "login",838 title: "Foo",839 username: "june",840 password: "secret",841 url: "https://foo.com",842 additionalUrls: ["https://bar.io", "https://baz.org"],843 });844845 846 847 expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);848 expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);849 expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);850 expect(vault.queryDomain("unrelated.com")).toEqual([]);851 });852853 it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {854 const vault = await import("./vault");855856 await vault.createVault("dedupe-additional-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 });864865 await vault.updateEntry(entry.id, {866 additionalUrls: [867 " https://login.foo.com ",868 "https://login.foo.com",869 "",870 " ",871 "https://accounts.foo.com",872 ],873 });874875 const stored = vault.getEntryById(entry.id);876 expect(stored?.additionalUrls).toEqual([877 "https://login.foo.com",878 "https://accounts.foo.com",879 ]);880881 await vault.updateEntry(entry.id, { additionalUrls: ["", " "] });882 expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();883 });884885 it("preserves email field through lock/unlock cycle", async () => {886 const vault = await import("./vault");887 await vault.createVault("preserve-email-pass-12345");888 await vault.addEntry({889 type: "login",890 title: "GitHub",891 username: "june",892 email: "[email protected]",893 password: "secret",894 url: "https://github.com",895 });896 vault.lock();897898 const ok = await vault.unlock("preserve-email-pass-12345");899 expect(ok).toBe(true);900 const entry = vault.getEntries().find((e) => e.title === "GitHub");901 expect(entry?.email).toBe("[email protected]");902 });903904 it("preserves additionalUrls through lock/unlock cycle", async () => {905 const vault = await import("./vault");906 await vault.createVault("preserve-urls-pass-12345");907 await vault.addEntry({908 type: "login",909 title: "Example",910 username: "june",911 password: "secret",912 url: "https://example.com",913 additionalUrls: [914 "https://beta.example.com",915 "https://staging.example.com",916 ],917 });918 vault.lock();919920 const ok = await vault.unlock("preserve-urls-pass-12345");921 expect(ok).toBe(true);922 const entry = vault.getEntries().find((e) => e.title === "Example");923 expect(entry?.additionalUrls).toEqual([924 "https://beta.example.com",925 "https://staging.example.com",926 ]);927 });928929 it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {930 const vault = await import("./vault");931 await vault.createVault("pwchanged-preserve-pass");932 const entry = await vault.addEntry({933 title: "Aged",934 username: "june",935 password: "secret",936 url: "https://aged.test",937 });938 939 940 const ts = entry.passwordChangedAt;941 expect(typeof ts).toBe("number");942 vault.lock();943944 expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);945 const restored = vault.getEntries().find((e) => e.id === entry.id);946 expect(restored?.passwordChangedAt).toBe(ts);947 });948949 it("normalizeEntry handles entries without passwordChangedAt", async () => {950 const vault = await import("./vault");951 await vault.createVault("pwchanged-absent-pass");952 953 const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });954 vault.lock();955956 expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);957 const restored = vault.getEntries().find((e) => e.id === note.id);958 expect(restored?.passwordChangedAt).toBeUndefined();959 });960961 it("applyMergedRemoteEntries does not advance the vault version", async () => {962 const vault = await import("./vault");963 await vault.createVault("syncver-pass-aaaaa");964 await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });965966 const before = await vault.getVaultVersion();967 await new Promise((r) => setTimeout(r, 2));968 await vault.applyMergedRemoteEntries(969 [970 {971 id: "remote-1",972 type: "login",973 title: "Remote",974 username: "r",975 password: "x",976 url: "https://r.test",977 createdAt: Date.now(),978 modifiedAt: Date.now(),979 },980 ],981 {},982 );983984 expect(await vault.getVaultVersion()).toBe(before);985 });986987 it("addEntry advances the vault version", async () => {988 const vault = await import("./vault");989 await vault.createVault("syncver-pass-bbbbb");990991 const before = await vault.getVaultVersion();992 await new Promise((r) => setTimeout(r, 2));993 await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });994995 expect(await vault.getVaultVersion()).toBeGreaterThan(before);996 });997998 it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {999 const vault = await import("./vault");1000 await vault.createVault("monotonic-pass-aaaaa");1001 const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });1002 const before = vault.getEntryById(e1.id)!.modifiedAt;10031004 1005 const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);1006 await vault.updateEntry(e1.id, { password: "p2" });1007 const after = vault.getEntryById(e1.id)!.modifiedAt;1008 expect(after).toBeGreaterThan(before);1009 spy.mockRestore();1010 });10111012 it("deletedAt is strictly greater than the entry's modifiedAt", async () => {1013 const vault = await import("./vault");1014 await vault.createVault("monotonic-pass-bbbbb");1015 const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });1016 const modAt = vault.getEntryById(e.id)!.modifiedAt;1017 await vault.deleteEntry(e.id);1018 const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;1019 expect(tomb.deletedAt!).toBeGreaterThan(modAt);1020 });10211022 it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {1023 const vault = await import("./vault");1024 await vault.createVault("rollback-pass-aaaaa");1025 const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });10261027 const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1028 1029 setSpy.mockImplementation(async (next: Record<string, unknown>) => {1030 if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1031 Object.assign(storage, next);1032 });10331034 await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();10351036 setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1037 1038 expect(vault.getEntryById(e.id)!.password).toBe("original");1039 });10401041 it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1042 const vault = await import("./vault");1043 await vault.createVault("rootkey-pass-aaaaa");1044 const before = vault.getRootKeyHex();1045 expect(before).toMatch(/^[0-9a-f]{64}$/);10461047 expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1048 expect(vault.getRootKeyHex()).toBe(before);10491050 1051 vault.lock();1052 expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1053 expect(vault.getRootKeyHex()).toBe(before);1054 });10551056 it("getRootKeyHex returns null when the vault is locked", async () => {1057 const vault = await import("./vault");1058 await vault.createVault("rootkey-pass-ccccc");1059 vault.lock();1060 expect(vault.getRootKeyHex()).toBeNull();1061 });10621063 it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1064 const vault = await import("./vault");1065 await vault.createVault("biometric-pass-12345");1066 const rootBefore = vault.getRootKeyHex();1067 const keyHex = vault.getDerivedKeyHex();1068 expect(rootBefore).not.toBeNull();1069 vault.lock();1070 1071 const r = await vault.unlockWithKey(keyHex);1072 expect(r.ok).toBe(true);1073 expect(vault.getRootKeyHex()).toBe(rootBefore);1074 });10751076 it("unlock_with_key_succeeds_without_escrow (PCH-01-009)", async () => {1077 const vault = await import("./vault");1078 await vault.createVault("legacy-pass-12345");1079 const keyHex = vault.getDerivedKeyHex();1080 vault.lock();1081 1082 1083 const slot = storage.peach_vault_a as Record<string, unknown>;1084 delete slot.recovery_salt;1085 delete slot.recovery_wrapped_root_key;1086 delete slot.pw_root_ciphertext;1087 delete slot.pw_root_nonce;1088 delete slot.pw_root_tag;1089 const r = await vault.unlockWithKey(keyHex);1090 expect(r.ok).toBe(true);1091 expect(vault.getRootKeyHex()).toBeNull();1092 });10931094 it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1095 const vault = await import("./vault");1096 await vault.createVault("duress-real-pass-aaaa");1097 await vault.setupDuress("duress-decoy-pass-bbbb");10981099 const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1100 const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;11011102 1103 expect(lenA()).toBe(lenB());11041105 1106 1107 await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1108 await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1109 expect(lenA()).toBe(lenB());1110 });11111112 it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1113 const vault = await import("./vault");1114 await vault.createVault("rollback-pass-bbbbb");1115 const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });11161117 const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1118 setSpy.mockImplementation(async (next: Record<string, unknown>) => {1119 if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1120 Object.assign(storage, next);1121 });11221123 await expect(vault.deleteEntry(e.id)).rejects.toThrow();11241125 setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1126 1127 expect(vault.getEntryById(e.id)).toBeDefined();1128 expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1129 });11301131 describe("password history", () => {1132 it("captures the old password on change, newest first", async () => {1133 const vault = await import("./vault");1134 await vault.createVault("history-pass-12345");1135 const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1136 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();11371138 await vault.updateEntry(e.id, { password: "second" });1139 await vault.updateEntry(e.id, { password: "third" });11401141 const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1142 expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1143 expect(vault.getEntryById(e.id)?.password).toBe("third");1144 });11451146 it("does not record history on a metadata-only update", async () => {1147 const vault = await import("./vault");1148 await vault.createVault("history-meta-12345");1149 const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });11501151 await vault.updateEntry(e.id, { title: "Renamed" });1152 await vault.updateEntry(e.id, { username: "newuser" });11531154 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1155 });11561157 it("records each history item with the age the password carried", async () => {1158 const vault = await import("./vault");1159 await vault.createVault("history-stamp-12345");1160 const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1161 const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1162 expect(firstChangedAt).toBeGreaterThan(0);11631164 await new Promise((resolve) => setTimeout(resolve, 2));1165 await vault.updateEntry(e.id, { password: "v2" });11661167 const hist = vault.getEntryById(e.id)!.passwordHistory!;1168 1169 expect(hist[0].changedAt).toBe(firstChangedAt);1170 });11711172 it("skips capture when re-saving the identical password", async () => {1173 const vault = await import("./vault");1174 await vault.createVault("history-noop-12345");1175 const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });11761177 await vault.updateEntry(e.id, { password: "same" });11781179 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1180 });11811182 it("does not capture an empty prior password (clear then set)", async () => {1183 const vault = await import("./vault");1184 await vault.createVault("history-empty-12345");1185 const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });11861187 await vault.updateEntry(e.id, { password: "now-has-one" });11881189 1190 expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1191 });11921193 it("restoring an old password re-records the swapped-out current one", async () => {1194 const vault = await import("./vault");1195 await vault.createVault("history-restore-12345");1196 const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1197 await vault.updateEntry(e.id, { password: "new" });11981199 1200 await vault.updateEntry(e.id, { password: "old" });12011202 const hist = vault.getEntryById(e.id)!.passwordHistory!;1203 expect(vault.getEntryById(e.id)?.password).toBe("old");1204 expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1205 });12061207 it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1208 const vault = await import("./vault");1209 await vault.createVault("history-persist-12345");1210 const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1211 await vault.updateEntry(e.id, { password: "p2" });12121213 vault.lock();1214 expect(await vault.unlock("history-persist-12345")).toBe(true);12151216 const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1217 expect(hist.map((h) => h.password)).toEqual(["p1"]);1218 });12191220 it("caps history at 50 entries, dropping the oldest", async () => {1221 const vault = await import("./vault");1222 await vault.createVault("history-cap-12345");1223 const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });12241225 for (let i = 1; i <= 55; i++) {1226 await vault.updateEntry(e.id, { password: `pw-${i}` });1227 }12281229 const hist = vault.getEntryById(e.id)!.passwordHistory!;1230 expect(hist).toHaveLength(50);1231 1232 expect(hist[0].password).toBe("pw-54");1233 expect(hist[hist.length - 1].password).toBe("pw-5");1234 });1235 });12361237 describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1238 it("drops unknown/attacker keys from a restored entry", async () => {1239 const vault = await import("./vault");1240 await vault.createVault("correct horse battery staple");1241 await vault.restoreEntries(1242 [1243 {1244 id: "e1",1245 type: "login",1246 title: "Bank",1247 username: "june",1248 password: "pw",1249 createdAt: 1,1250 modifiedAt: 2,1251 evil: "payload",1252 isAdmin: true,1253 nested: { junk: 1 },1254 } as unknown as import("../shared/types").VaultEntry,1255 ],1256 "replace",1257 );1258 const restored = vault.getEntries().find((e) => e.id === "e1")!;1259 expect(restored.password).toBe("pw");1260 expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1261 expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1262 expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1263 });12641265 it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1266 const vault = await import("./vault");1267 await vault.createVault("correct horse battery staple");1268 await vault.restoreEntries(1269 [1270 {1271 id: "e2",1272 type: "login",1273 title: "Bank",1274 password: "pw",1275 createdAt: 1,1276 modifiedAt: 2,1277 canary: { webhookUrl: "http://attacker.example.com/x" },1278 } as unknown as import("../shared/types").VaultEntry,1279 ],1280 "replace",1281 );1282 const restored = vault.getEntries().find((e) => e.id === "e2")!;1283 expect(restored).toBeDefined();1284 expect(restored.canary).toBeUndefined();1285 });12861287 it("preserves a well-formed HTTPS canary webhook", async () => {1288 const vault = await import("./vault");1289 await vault.createVault("correct horse battery staple");1290 await vault.restoreEntries(1291 [1292 {1293 id: "e3",1294 type: "login",1295 title: "Bank",1296 password: "pw",1297 createdAt: 1,1298 modifiedAt: 2,1299 canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1300 } as unknown as import("../shared/types").VaultEntry,1301 ],1302 "replace",1303 );1304 const restored = vault.getEntries().find((e) => e.id === "e3")!;1305 expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1306 expect(restored.canary?.label).toBe("watch");1307 });13081309 it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1310 const vault = await import("./vault");1311 await vault.createVault("correct horse battery staple");1312 await vault.restoreEntries(1313 [1314 {1315 id: "e4",1316 type: "login",1317 title: "Has bad passkey",1318 createdAt: 1,1319 modifiedAt: 2,1320 passkey: { rpId: "example.com", publicKeyCose: "x" },1321 } as unknown as import("../shared/types").VaultEntry,1322 ],1323 "replace",1324 );1325 expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1326 });13271328 it("migrates a pre-v2.11 flat-passkey backup entry into the passkey sub-object", async () => {1329 const vault = await import("./vault");1330 await vault.createVault("correct horse battery staple");1331 await vault.restoreEntries(1332 [1333 {1334 id: "e5",1335 type: "passkey",1336 title: "Legacy Passkey",1337 createdAt: 1,1338 modifiedAt: 2,1339 rpId: "example.com",1340 rpName: "Example",1341 credentialId: "cred-123",1342 publicKeyCose: "cose-blob",1343 userHandle: "uh",1344 userName: "june",1345 userDisplayName: "June",1346 algorithm: -7,1347 } as unknown as import("../shared/types").VaultEntry,1348 ],1349 "replace",1350 );1351 const restored = vault.getEntries().find((e) => e.id === "e5")!;1352 expect(restored).toBeDefined();1353 expect(restored.passkey?.credentialId).toBe("cred-123");1354 1355 expect((restored as unknown as Record<string, unknown>).credentialId).toBeUndefined();1356 });1357 });13581359 describe("MV-8 rotateSyncIdentity", () => {1360 it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1361 const vault = await import("./vault");1362 await vault.createVault("correct horse battery staple");1363 const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1364 const { upsertPairedDevice, listPairedDevices } = await import(1365 "./paired-devices"1366 );1367 await upsertPairedDevice({1368 deviceId: "d1",1369 deviceName: "Phone",1370 deviceType: "android",1371 longTermPublicKeyHex: "a".repeat(64),1372 signingPublicKeyHex: "b".repeat(64),1373 pairedAt: 1,1374 trustSource: "direct",1375 });13761377 const rootBefore = vault.getRootKeyHex();1378 const res = await vault.rotateSyncIdentity();1379 expect(res.ok).toBe(true);1380 expect(vault.getRootKeyHex()).not.toBe(rootBefore);1381 expect(await listPairedDevices()).toHaveLength(0);1382 1383 expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");13841385 vault.lock();1386 expect(await vault.unlock("correct horse battery staple")).toBe(true);1387 expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1388 });13891390 it("invalidates the old recovery words and flags recovery-pending", async () => {1391 const vault = await import("./vault");1392 await vault.createVault("correct horse battery staple");1393 const wordsBefore = (await vault.getRecoveryWords()).join(" ");13941395 await vault.rotateSyncIdentity();13961397 const wordsAfter = (await vault.getRecoveryWords()).join(" ");1398 expect(wordsAfter).not.toBe(wordsBefore);1399 const { isRecoveryConfirmPending } = await import("./recovery-pending");1400 expect(await isRecoveryConfirmPending()).toBe(true);1401 });14021403 it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1404 const vault = await import("./vault");1405 await vault.createVault("correct horse battery staple");1406 const { createSyncVaultAdapter } = await import(1407 "./sync-vault-adapter"1408 );1409 const adapter = createSyncVaultAdapter();1410 const vidBefore = await adapter.computeVaultId();14111412 await vault.rotateSyncIdentity();14131414 const vidAfter = await adapter.computeVaultId();1415 expect(vidAfter).not.toBe(vidBefore);1416 });1417 });14181419 describe("LV-1 NFC master password normalization", () => {1420 const CAFE_BASE = "café";1421 const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1422 const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;14231424 it("a vault created with the NFD form unlocks with the NFC form", async () => {1425 const vault = await import("./vault");1426 expect(PW_NFD).not.toBe(PW_NFC);1427 await vault.createVault(PW_NFD);1428 vault.lock();1429 expect(await vault.unlock(PW_NFC)).toBe(true);1430 });14311432 it("a legacy vault sealed with raw non-NFC bytes still unlocks and is healed to NFC", async () => {1433 const vault = await import("./vault");1434 const { deriveKeyRaw, generateSalt } = await import("../crypto/argon2");1435 await vault.createVault("base-password-12345");14361437 1438 1439 const rawSalt = generateSalt();1440 const rawKey = await deriveKeyRaw(PW_NFD, rawSalt);1441 await vault.changePasswordWithNewKey(rawSalt, rawKey);1442 vault.lock();14431444 1445 expect(await vault.unlock(PW_NFD)).toBe(true);14461447 1448 1449 vault.lock();1450 expect(await vault.unlock(PW_NFC)).toBe(true);1451 });1452 });14531454 describe("LV-7 updateSettings proto-pollution filter", () => {1455 it("strips __proto__/constructor/prototype keys while persisting real settings", async () => {1456 const vault = await import("./vault");1457 await vault.createVault("correct horse battery staple");14581459 await vault.updateSettings({1460 theme: "dark",1461 ["__proto__"]: { polluted: true },1462 constructor: { polluted: true },1463 } as never);14641465 expect(({} as Record<string, unknown>).polluted).toBeUndefined();1466 expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();1467 expect(vault.getSettings().theme).toBe("dark");1468 });1469 });1470});