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>;78function deferred(): { promise: Promise<void>; resolve: () => void } {9 let resolve!: () => void;10 const promise = new Promise<void>((done) => { resolve = done; });11 return { promise, resolve };12}1314describe("background vault", () => {15 let storage: StorageShape;16 let sessionStorage: StorageShape;17 let alarms = { created: [] as Array<{ name: string; delayInMinutes: number }>, cleared: [] as string[] };1819 beforeEach(() => {20 vi.resetModules();21 storage = {};22 sessionStorage = {};23 alarms = { created: [], cleared: [] };2425 const chromeMock = {26 storage: {27 local: {28 get: vi.fn(async (key?: string | string[]) => {29 if (!key) return storage;30 if (typeof key === "string") {31 return { [key]: storage[key] };32 }3334 return key.reduce<Record<string, unknown>>((result, entry) => {35 result[entry] = storage[entry];36 return result;37 }, {});38 }),39 set: vi.fn(async (next: Record<string, unknown>) => {40 Object.assign(storage, next);41 }),42 remove: vi.fn(async (keys: string | string[]) => {43 const list = Array.isArray(keys) ? keys : [keys];44 for (const key of list) {45 delete storage[key];46 }47 }),48 },49 session: {50 get: vi.fn(async (key?: string | string[]) => {51 if (!key) return sessionStorage;52 if (typeof key === "string") return { [key]: sessionStorage[key] };53 return key.reduce<Record<string, unknown>>((result, entry) => {54 result[entry] = sessionStorage[entry];55 return result;56 }, {});57 }),58 set: vi.fn(async (next: Record<string, unknown>) => {59 Object.assign(sessionStorage, next);60 }),61 remove: vi.fn(async (keys: string | string[]) => {62 const list = Array.isArray(keys) ? keys : [keys];63 for (const key of list) delete sessionStorage[key];64 }),65 clear: vi.fn(async () => {66 for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];67 }),68 },69 },70 alarms: {71 clear: vi.fn((name: string) => {72 alarms.cleared.push(name);73 }),74 create: vi.fn((name: string, info: { delayInMinutes: number }) => {75 alarms.created.push({ name, delayInMinutes: info.delayInMinutes });76 }),77 onAlarm: { addListener: vi.fn() },78 },79 idle: {80 setDetectionInterval: vi.fn(),81 onStateChanged: { addListener: vi.fn() },82 },83 } as unknown as typeof chrome;8485 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8687 88 vi.stubGlobal("fetch", vi.fn(async () => {89 throw new Error("network disabled in tests");90 }));91 });9293 it("creates a vault and stores encrypted payload", async () => {94 const vault = await import("./vault");9596 await vault.createVault("correct horse battery staple");9798 expect(storage.peach_vault_a).toBeDefined();99 expect((storage.peach_vault_a as StoredVault).ciphertext).toBeTruthy();100 expect(vault.getEntries()).toEqual([]);101 expect(alarms.created).toContainEqual({102 name: "autolock",103 delayInMinutes: 5,104 });105 });106107 it("writes both slots A and B on vault creation", async () => {108 const vault = await import("./vault");109 await vault.createVault("correct horse battery staple");110111 expect(storage.peach_vault_a).toBeDefined();112 expect(storage.peach_vault_b).toBeDefined();113 expect(storage.peach_duress).toBeUndefined();114 expect(storage.peach_vault).toBeUndefined();115 expect(storage.peach_decoy_vault).toBeUndefined();116 });117118 it("keeps the PIN wrapper in session storage and invalidates it on password change", async () => {119 const vault = await import("./vault");120 const password = "correct horse battery staple";121122 await vault.createVault(password);123 expect(await vault.setupPinUnlock(password, "123")).toBe(false);124 expect(await vault.setupPinUnlock(password, "1234567890123")).toBe(false);125 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);126 expect((sessionStorage.peach_pin_wrap as { ciphertext: string }).ciphertext).toBeTruthy();127 expect(storage.peach_pin_wrap).toBeUndefined();128129 vault.lock();130 expect(await vault.unlockWithPin("000000")).toBe(false);131 expect(await vault.unlockWithPin("1234")).toBe(true);132133 expect(await vault.changePassword(password, "new correct horse battery"))134 .toBe(true);135 vault.lock();136 expect(await vault.unlockWithPin("1234")).toBe(false);137 expect(await vault.unlock("new correct horse battery")).toBe(true);138 });139140 it("requires the master password after three incorrect PIN attempts", async () => {141 const vault = await import("./vault");142 const password = "correct horse battery staple";143144 await vault.createVault(password);145 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);146 vault.lock();147148 expect(await vault.unlockWithPin("0000")).toBe(false);149 expect(await vault.unlockWithPin("0000")).toBe(false);150 expect(await vault.unlockWithPin("0000")).toBe(false);151 expect(await vault.unlockWithPin("1234")).toBe(false);152 expect(await vault.unlock(password)).toBe(true);153154 vault.lock();155 expect(await vault.unlockWithPin("1234")).toBe(true);156 });157158 it("cannot unlock from a copied local-storage snapshot after session storage is cleared", async () => {159 const vault = await import("./vault");160 const password = "correct horse battery staple";161162 await vault.createVault(password);163 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);164 const copiedLocalStorage = { ...storage };165 await (chrome.storage.session as typeof chrome.storage.session).clear();166 vault.lock();167168 expect(copiedLocalStorage.peach_pin_wrap).toBeUndefined();169 await expect(vault.unlockWithPin("1234")).resolves.toBe(false);170 await expect(vault.getPinUnlockStatus()).resolves.toMatchObject({171 configured: true,172 available: false,173 attemptsRemaining: 0,174 });175 });176177 it("slot A and slot B have matching ciphertext size at fresh install", async () => {178 const vault = await import("./vault");179 await vault.createVault("correct horse battery staple");180181 const a = storage.peach_vault_a as StoredVault;182 const b = storage.peach_vault_b as StoredVault;183 expect(a.ciphertext.length).toBe(b.ciphertext.length);184 });185186 it("does not expose a plaintext duress flag after setupDuress", async () => {187 const vault = await import("./vault");188 await vault.createVault("real-password-12345");189 await vault.setupDuress("decoy-password-67890");190191 expect(storage.peach_duress).toBeUndefined();192 expect(storage.peach_decoy_vault).toBeUndefined();193 const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));194 195 196 for (const key of keys) {197 expect(198 [199 "peach_vault_a",200 "peach_vault_b",201 "peach_settings",202 "peach_vault_history",203 "peach_last_modified_at",204 ]205 ).toContain(key);206 }207 expect(keys).toContain("peach_vault_a");208 expect(keys).toContain("peach_vault_b");209 });210211 it("unlocks with real password and loads real entries", async () => {212 const vault = await import("./vault");213 await vault.createVault("real-password-12345");214 await vault.addEntry({215 title: "GitHub",216 username: "june",217 password: "realsecret",218 url: "github.com",219 });220 vault.lock();221222 const ok = await vault.unlock("real-password-12345");223 expect(ok).toBe(true);224 expect(vault.getEntries()).toHaveLength(1);225 expect(vault.getEntries()[0].title).toBe("GitHub");226 });227228 it("unlocks with duress password and loads empty entries", async () => {229 const vault = await import("./vault");230 await vault.createVault("real-password-12345");231 await vault.addEntry({232 title: "GitHub",233 username: "june",234 password: "realsecret",235 url: "github.com",236 });237 await vault.setupDuress("decoy-password-67890");238 vault.lock();239240 const ok = await vault.unlock("decoy-password-67890");241 expect(ok).toBe(true);242 expect(vault.getEntries()).toEqual([]);243 });244245 it("HOLE 5: the duress vault has no sync identity and cannot sync", async () => {246 const vault = await import("./vault");247 await vault.createVault("real-password-12345");248 await vault.addEntry({ type: "login", title: "GitHub", password: "realsecret" });249 await vault.setupDuress("decoy-password-67890");250251 252 expect(vault.getRootKeyHex()).toBeTruthy();253 vault.lock();254255 expect(await vault.unlock("decoy-password-67890")).toBe(true);256 257 258 259 expect(vault.getRootKeyHex()).toBeNull();260261 262 263 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");264 const adapter = createSyncVaultAdapter();265 await expect(adapter.computeVaultId()).rejects.toThrow();266 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toThrow();267 vault.lock();268 });269270 it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {271 272 273 274 275 const argon2 = await import("../crypto/argon2");276 const deriveSpy = vi.spyOn(argon2, "deriveKey");277278 const vault = await import("./vault");279 await vault.createVault("real-password-12345");280 await vault.setupDuress("decoy-password-67890");281 vault.lock();282283 deriveSpy.mockClear();284 await vault.unlock("real-password-12345");285 expect(deriveSpy).toHaveBeenCalledTimes(2);286 vault.lock();287288 deriveSpy.mockClear();289 await vault.unlock("decoy-password-67890");290 expect(deriveSpy).toHaveBeenCalledTimes(2);291 vault.lock();292293 deriveSpy.mockClear();294 await vault.unlock("wrong-password-xxxxx");295 expect(deriveSpy).toHaveBeenCalledTimes(2);296297 deriveSpy.mockRestore();298 });299300 it("rejects wrong passwords", async () => {301 const vault = await import("./vault");302 await vault.createVault("real-password-12345");303 vault.lock();304305 const ok = await vault.unlock("nope-nope-nope");306 expect(ok).toBe(false);307 });308309 it("persists duressConfigured across lock/unlock in encrypted payload", async () => {310 const vault = await import("./vault");311 await vault.createVault("real-password-12345");312 await vault.setupDuress("decoy-password-67890");313 vault.lock();314315 await vault.unlock("real-password-12345");316 expect(await vault.isDuressEnabled()).toBe(true);317318 await vault.disableDuress();319 expect(await vault.isDuressEnabled()).toBe(false);320 vault.lock();321322 await vault.unlock("real-password-12345");323 expect(await vault.isDuressEnabled()).toBe(false);324 });325326 it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {327 const vault = await import("./vault");328329 await vault.createVault("correct horse battery staple");330 const entry = await vault.addEntry({331 title: "GitHub",332 username: "june",333 password: "secret123",334 url: "github.com",335 });336337 await vault.deleteEntry(entry.id);338339 expect(vault.getEntries()).toEqual([]);340341 const stored = storage.peach_vault_a as StoredVault;342 const salt = Uint8Array.from(343 stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))344 );345 const key = await deriveKey("correct horse battery staple", salt);346 const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");347 const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };348349 expect(parsed.entries).toHaveLength(1);350 expect(parsed.entries[0].id).toBe(entry.id);351 expect(typeof parsed.entries[0].deletedAt).toBe("number");352 });353354 it("accepts imported entries without a username or url", async () => {355 const vault = await import("./vault");356357 await vault.createVault("correct horse battery staple");358 const entry = await vault.addEntry({359 title: "Imported Login",360 username: "",361 password: "secret123",362 url: "",363 notes: "migrated from another manager",364 });365366 expect(entry.username).toBe("");367 expect(entry.url).toBe("");368 expect(vault.getEntries()).toHaveLength(1);369 });370371 it("changePassword happy path — new password unlocks, old is rejected", async () => {372 const vault = await import("./vault");373374 await vault.createVault("old-pass-abc123");375 await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });376 await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });377378 expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);379380 vault.lock();381 expect(await vault.unlock("new-pass-xyz789")).toBe(true);382 expect(vault.getEntries()).toHaveLength(2);383384 vault.lock();385 expect(await vault.unlock("old-pass-abc123")).toBe(false);386 });387388 it("stamps peach_last_modified_at on createVault", async () => {389 const vault = await import("./vault");390 const before = Date.now();391 await new Promise((resolve) => setTimeout(resolve, 1));392393 await vault.createVault("stamp-create-pass");394395 expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);396 });397398 it("rejects a slot A persist that races with lock", async () => {399 const vault = await import("./vault");400 await vault.createVault("persist-epoch-pass");401 const slotBefore = JSON.stringify(storage.peach_vault_a);402 const readStarted = deferred();403 const releaseRead = deferred();404 const localGet = chrome.storage.local.get as unknown as ReturnType<typeof vi.fn>;405 localGet.mockImplementation(async (key?: string | string[]) => {406 if (key === "peach_vault_a") {407 readStarted.resolve();408 await releaseRead.promise;409 }410 if (!key) return storage;411 const keys = Array.isArray(key) ? key : [key];412 return keys.reduce<StorageShape>((result, entry) => {413 result[entry] = storage[entry];414 return result;415 }, {});416 });417418 const saving = vault.addEntry({ title: "stale", username: "", password: "pw", url: "" });419 await readStarted.promise;420 await vault.lock();421 releaseRead.resolve();422423 await expect(saving).rejects.toThrow("vault_locked");424 expect(JSON.stringify(storage.peach_vault_a)).toBe(slotBefore);425 expect(vault.isUnlocked()).toBe(false);426 });427428 it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {429 const vault = await import("./vault");430 await vault.createVault("stamp-entry-pass");431 let previous = Number(storage.peach_last_modified_at);432 await new Promise((resolve) => setTimeout(resolve, 1));433 const entry = await vault.addEntry({434 title: "Stamp",435 username: "",436 password: "p",437 url: "",438 });439 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);440441 previous = Number(storage.peach_last_modified_at);442 await new Promise((resolve) => setTimeout(resolve, 1));443 await vault.updateEntry(entry.id, { title: "Stamped" });444 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);445446 previous = Number(storage.peach_last_modified_at);447 await new Promise((resolve) => setTimeout(resolve, 1));448 await vault.deleteEntry(entry.id);449 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);450 });451452 it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {453 const vault = await import("./vault");454 await vault.createVault("stamp-real-pass");455456 let previous = Number(storage.peach_last_modified_at);457 await new Promise((resolve) => setTimeout(resolve, 1));458 await vault.setupDuress("stamp-decoy-pass");459 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);460461 previous = Number(storage.peach_last_modified_at);462 await new Promise((resolve) => setTimeout(resolve, 1));463 expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);464 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);465 });466467 it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {468 const vault = await import("./vault");469470 await vault.createVault("correct-pass-abcde");471 const slotABefore = JSON.stringify(storage.peach_vault_a);472473 expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);474 expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);475476 vault.lock();477 expect(await vault.unlock("correct-pass-abcde")).toBe(true);478 });479480 it("changePassword with duress configured leaves slot B byte-identical", async () => {481 const vault = await import("./vault");482483 await vault.createVault("real-pass-abcde");484 await vault.setupDuress("duress-pass-xyzzz");485486 const slotBBefore = JSON.stringify(storage.peach_vault_b);487 expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);488 expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);489490 491 vault.lock();492 expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);493 expect(vault.getEntries()).toEqual([]);494 });495496 it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {497 const vault = await import("./vault");498499 await vault.createVault("pass-abcdef");500 await vault.addEntry({ title: "One", username: "", password: "1", url: "" });501 await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });502 await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });503504 await expect(vault.restoreEntries([], "replace")).rejects.toThrow(505 /refuse to replace with empty entry set/i506 );507 expect(vault.getEntries()).toHaveLength(3);508 });509510 it("changePassword swaps the active key before Slot B is regenerated", async () => {511 const vault = await import("./vault");512513 await vault.createVault("correct-pass-12345");514 const entry = await vault.addEntry({515 title: "GitHub",516 username: "june",517 password: "secret123",518 url: "https://github.com",519 });520521 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;522 const originalSet = setMock.getMockImplementation();523 let injectedDuringSlotBWrite = false;524525 setMock.mockImplementation(async (next: Record<string, unknown>) => {526 Object.assign(storage, next);527528 if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {529 injectedDuringSlotBWrite = true;530 await vault.updateEntry(entry.id, { notes: "updated during password change" });531 }532 });533534 expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);535536 setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {537 Object.assign(storage, next);538 }));539540 vault.lock();541 expect(await vault.unlock("new-pass-67890")).toBe(true);542 expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");543544 vault.lock();545 expect(await vault.unlock("correct-pass-12345")).toBe(false);546 });547548 it("restoreEntries replace mode replaces existing entries", async () => {549 const vault = await import("./vault");550551 await vault.createVault("replace-pass-12345");552 await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });553 await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });554555 const incoming = [556 {557 id: "entry-c",558 type: "login" as const,559 title: "C",560 username: "june",561 password: "three",562 url: "https://c.test",563 createdAt: 10,564 modifiedAt: 10,565 },566 {567 id: "entry-d",568 type: "login" as const,569 title: "D",570 username: "june",571 password: "four",572 url: "https://d.test",573 createdAt: 20,574 modifiedAt: 20,575 },576 ];577578 await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({579 added: 2,580 updated: 0,581 skipped: 0,582 });583584 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);585 });586587 it("restoreEntries merge mode lets newer incoming entries win", async () => {588 const vault = await import("./vault");589590 await vault.createVault("merge-pass-12345");591 const existing = await vault.addEntry({592 title: "GitHub",593 username: "june",594 password: "old-secret",595 url: "https://github.com",596 });597598 await expect(599 vault.restoreEntries(600 [601 {602 ...existing,603 password: "new-secret",604 modifiedAt: existing.modifiedAt + 100,605 },606 ],607 "merge"608 )609 ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });610611 expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");612 });613614 it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {615 const vault = await import("./vault");616617 await vault.createVault("merge-pass-67890");618 const existing = await vault.addEntry({619 title: "GitHub",620 username: "june",621 password: "current-secret",622 url: "https://github.com",623 });624625 await expect(626 vault.restoreEntries(627 [628 {629 ...existing,630 password: "stale-secret",631 modifiedAt: existing.modifiedAt - 100,632 },633 ],634 "merge"635 )636 ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });637638 expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");639 });640641 it("clearVaultEntries without explicit confirmation throws", async () => {642 const vault = await import("./vault");643644 await vault.createVault("clear-pass-12345");645 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });646647 const clearVaultEntries = vault.clearVaultEntries as unknown as (648 confirmation?: unknown649 ) => Promise<void>;650651 await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);652 await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);653 expect(vault.getEntries()).toHaveLength(1);654 });655656 it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {657 const vault = await import("./vault");658659 await vault.createVault("clear-pass-67890");660 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });661 await vault.addEntry({ title: "B", username: "", password: "two", url: "" });662 await vault.addEntry({ title: "C", username: "", password: "three", url: "" });663664 await vault.clearVaultEntries("REPLACE_ALL");665666 expect(vault.getEntries()).toEqual([]);667 expect(vault.getDeletedEntries()).toHaveLength(3);668 expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);669 });670671 it("deduplicateEntries throws in decoy mode", async () => {672 const vault = await import("./vault");673674 await vault.createVault("real-pass-12345");675 await vault.addEntry({676 title: "GitHub",677 username: "june",678 password: "secret123",679 url: "https://github.com",680 });681 await vault.setupDuress("duress-pass-12345");682 vault.lock();683684 expect(await vault.unlock("duress-pass-12345")).toBe(true);685 await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);686 });687688 it("deduplicateEntries removes older duplicates in real mode", async () => {689 const vault = await import("./vault");690691 await vault.createVault("dedupe-pass-12345");692 await vault.addEntry({693 title: "GitHub",694 username: "june",695 password: "old-secret",696 url: "https://github.com",697 });698 await new Promise<void>((resolve) => setTimeout(resolve, 5));699 const newer = await vault.addEntry({700 title: "GitHub",701 username: "june",702 password: "new-secret",703 url: "https://github.com",704 });705706 expect(await vault.deduplicateEntries()).toBe(1);707 expect(vault.getEntries()).toHaveLength(1);708 expect(vault.getEntries()[0].id).toBe(newer.id);709 expect(vault.getDeletedEntries()).toHaveLength(1);710 });711712 it("updateEntry with an empty password on a login entry clears the password", async () => {713 const vault = await import("./vault");714715 await vault.createVault("update-pass-12345");716 const entry = await vault.addEntry({717 title: "GitHub",718 username: "june",719 password: "secret123",720 url: "https://github.com",721 });722723 await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();724 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();725 });726727 it("updateEntry with an empty password on a note entry is allowed", async () => {728 const vault = await import("./vault");729730 await vault.createVault("note-pass-12345");731 const entry = await vault.addEntry({732 type: "note",733 title: "Secure note",734 notes: "keep me",735 });736737 await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();738 expect(vault.getEntryById(entry.id)).toMatchObject({739 type: "note",740 notes: "updated",741 });742 });743744 it("lock zeroes the derived key and resets unlock state", async () => {745 const aes = await import("./aes");746 const zeroKeySpy = vi.spyOn(aes, "zeroKey");747 const vault = await import("./vault");748749 await vault.createVault("lock-pass-12345");750 expect(vault.isUnlocked()).toBe(true);751752 vault.lock();753754 expect(zeroKeySpy).toHaveBeenCalled();755 expect(vault.isUnlocked()).toBe(false);756 zeroKeySpy.mockRestore();757 });758759 it("lock clears in-memory entries", async () => {760 const vault = await import("./vault");761762 await vault.createVault("lock-pass-67890");763 const entry = await vault.addEntry({764 title: "GitHub",765 username: "june",766 password: "secret123",767 url: "https://github.com",768 });769770 expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");771 vault.lock();772 expect(vault.getEntryById(entry.id)).toBeUndefined();773 });774775 it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {776 const vault = await import("./vault");777778 await vault.createVault("real-pass-67890");779 vault.lock();780781 expect(await vault.unlock("totally-wrong-password")).toBe(false);782 expect(vault.isUnlocked()).toBe(false);783 });784785 it("isUnlocked tracks the full lock lifecycle", async () => {786 const vault = await import("./vault");787788 expect(vault.isUnlocked()).toBe(false);789 await vault.createVault("unlock-pass-12345");790 expect(vault.isUnlocked()).toBe(true);791 vault.lock();792 expect(vault.isUnlocked()).toBe(false);793 expect(await vault.unlock("unlock-pass-12345")).toBe(true);794 expect(vault.isUnlocked()).toBe(true);795 vault.lock();796 expect(vault.isUnlocked()).toBe(false);797 });798799 it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {800 const vault = await import("./vault");801802 await vault.createVault("quota-pass-12345");803804 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;805 setMock.mockImplementation(async (next: Record<string, unknown>) => {806 if ("peach_vault_a" in next || "peach_vault_b" in next) {807 throw new Error("QUOTA_BYTES quota reached");808 }809 Object.assign(storage, next);810 });811812 await expect(813 vault.addEntry({814 title: "GitHub",815 username: "june",816 password: "secret123",817 url: "https://github.com",818 })819 ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");820 });821822 it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {823 const vault = await import("./vault");824825 await vault.createVault("settings-pass-12345");826827 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;828 setMock.mockImplementation(async (next: Record<string, unknown>) => {829 if ("peach_settings" in next) {830 throw new Error("QUOTA_BYTES quota reached");831 }832 Object.assign(storage, next);833 });834835 await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(836 "STORAGE_QUOTA_EXCEEDED"837 );838 });839840 it("card entry round-trips through vault — all fields preserved", async () => {841 const vault = await import("./vault");842843 await vault.createVault("card-test-pass-12345");844 const entry = await vault.addEntry({845 type: "card",846 title: "Personal Visa",847 cardholderName: "Jane Smith",848 cardNumber: "4111111111111111",849 expMonth: "05",850 expYear: "2028",851 cvc: "123",852 cardNickname: "Personal Visa",853 network: "visa",854 last4: "1111",855 });856857 expect(entry.type).toBe("card");858 expect(entry.cardholderName).toBe("Jane Smith");859 expect(entry.cardNumber).toBe("4111111111111111");860 expect(entry.expMonth).toBe("05");861 expect(entry.expYear).toBe("2028");862 expect(entry.cvc).toBe("123");863 expect(entry.network).toBe("visa");864 expect(entry.last4).toBe("1111");865866 vault.lock();867 await vault.unlock("card-test-pass-12345");868 const entries = vault.getEntries();869 expect(entries).toHaveLength(1);870 expect(entries[0].cardNumber).toBe("4111111111111111");871 expect(entries[0].cvc).toBe("123");872 });873874 it("updateEntry — cannot clear card number on card entry", async () => {875 const vault = await import("./vault");876877 await vault.createVault("card-guard-pass-12345");878 const entry = await vault.addEntry({879 type: "card",880 title: "My Card",881 cardholderName: "Jane Smith",882 cardNumber: "4111111111111111",883 expMonth: "05",884 expYear: "2028",885 cvc: "123",886 });887888 await expect(889 vault.updateEntry(entry.id, { cardNumber: "" })890 ).rejects.toThrow("card number is required for card entries");891892 const entries = vault.getEntries();893 expect(entries[0].cardNumber).toBe("4111111111111111");894 });895896 it("declineCardSaveOffer — stores fingerprint in settings", async () => {897 const vault = await import("./vault");898899 await vault.createVault("decline-pass-12345");900 await vault.declineCardSaveOffer("1111", "05", "2028");901902 const settings = vault.getSettings();903 expect(settings.declinedCardFingerprints).toHaveLength(1);904 });905906 it("isCardDeclined — returns true for previously declined card", async () => {907 const vault = await import("./vault");908909 await vault.createVault("declined-check-pass-12345");910 await vault.declineCardSaveOffer("1111", "05", "2028");911912 expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);913 });914915 it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {916 const vault = await import("./vault");917918 await vault.createVault("declined-diff-pass-12345");919 await vault.declineCardSaveOffer("1111", "05", "2028");920921 expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);922 });923924 it("queryDomain matches an entry against its additionalUrls", async () => {925 const vault = await import("./vault");926927 await vault.createVault("multi-domain-pass-12345");928 const entry = await vault.addEntry({929 type: "login",930 title: "Foo",931 username: "june",932 password: "secret",933 url: "https://foo.com",934 additionalUrls: ["https://bar.io", "https://baz.org"],935 });936937 938 939 expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);940 expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);941 expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);942 expect(vault.queryDomain("unrelated.com")).toEqual([]);943 });944945 it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {946 const vault = await import("./vault");947948 await vault.createVault("dedupe-additional-pass-12345");949 const entry = await vault.addEntry({950 type: "login",951 title: "Foo",952 username: "june",953 password: "secret",954 url: "https://foo.com",955 });956957 await vault.updateEntry(entry.id, {958 additionalUrls: [959 " https://login.foo.com ",960 "https://login.foo.com",961 "",962 " ",963 "https://accounts.foo.com",964 ],965 });966967 const stored = vault.getEntryById(entry.id);968 expect(stored?.additionalUrls).toEqual([969 "https://login.foo.com",970 "https://accounts.foo.com",971 ]);972973 await vault.updateEntry(entry.id, { additionalUrls: ["", " "] });974 expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();975 });976977 it("preserves email field through lock/unlock cycle", async () => {978 const vault = await import("./vault");979 await vault.createVault("preserve-email-pass-12345");980 await vault.addEntry({981 type: "login",982 title: "GitHub",983 username: "june",984 email: "[email protected]",985 password: "secret",986 url: "https://github.com",987 });988 vault.lock();989990 const ok = await vault.unlock("preserve-email-pass-12345");991 expect(ok).toBe(true);992 const entry = vault.getEntries().find((e) => e.title === "GitHub");993 expect(entry?.email).toBe("[email protected]");994 });995996 it("preserves additionalUrls through lock/unlock cycle", async () => {997 const vault = await import("./vault");998 await vault.createVault("preserve-urls-pass-12345");999 await vault.addEntry({1000 type: "login",1001 title: "Example",1002 username: "june",1003 password: "secret",1004 url: "https://example.com",1005 additionalUrls: [1006 "https://beta.example.com",1007 "https://staging.example.com",1008 ],1009 });1010 vault.lock();10111012 const ok = await vault.unlock("preserve-urls-pass-12345");1013 expect(ok).toBe(true);1014 const entry = vault.getEntries().find((e) => e.title === "Example");1015 expect(entry?.additionalUrls).toEqual([1016 "https://beta.example.com",1017 "https://staging.example.com",1018 ]);1019 });10201021 it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {1022 const vault = await import("./vault");1023 await vault.createVault("pwchanged-preserve-pass");1024 const entry = await vault.addEntry({1025 title: "Aged",1026 username: "june",1027 password: "secret",1028 url: "https://aged.test",1029 });1030 1031 1032 const ts = entry.passwordChangedAt;1033 expect(typeof ts).toBe("number");1034 vault.lock();10351036 expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);1037 const restored = vault.getEntries().find((e) => e.id === entry.id);1038 expect(restored?.passwordChangedAt).toBe(ts);1039 });10401041 it("normalizeEntry handles entries without passwordChangedAt", async () => {1042 const vault = await import("./vault");1043 await vault.createVault("pwchanged-absent-pass");1044 1045 const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });1046 vault.lock();10471048 expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);1049 const restored = vault.getEntries().find((e) => e.id === note.id);1050 expect(restored?.passwordChangedAt).toBeUndefined();1051 });10521053 it("applyMergedRemoteEntries does not advance the vault version", async () => {1054 const vault = await import("./vault");1055 await vault.createVault("syncver-pass-aaaaa");1056 await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });10571058 const before = await vault.getVaultVersion();1059 await new Promise((r) => setTimeout(r, 2));1060 await vault.applyMergedRemoteEntries(1061 [1062 {1063 id: "remote-1",1064 type: "login",1065 title: "Remote",1066 username: "r",1067 password: "x",1068 url: "https://r.test",1069 createdAt: Date.now(),1070 modifiedAt: Date.now(),1071 },1072 ],1073 {},1074 );10751076 expect(await vault.getVaultVersion()).toBe(before);1077 });10781079 it("addEntry advances the vault version", async () => {1080 const vault = await import("./vault");1081 await vault.createVault("syncver-pass-bbbbb");10821083 const before = await vault.getVaultVersion();1084 await new Promise((r) => setTimeout(r, 2));1085 await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });10861087 expect(await vault.getVaultVersion()).toBeGreaterThan(before);1088 });10891090 it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {1091 const vault = await import("./vault");1092 await vault.createVault("monotonic-pass-aaaaa");1093 const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });1094 const before = vault.getEntryById(e1.id)!.modifiedAt;10951096 1097 const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);1098 await vault.updateEntry(e1.id, { password: "p2" });1099 const after = vault.getEntryById(e1.id)!.modifiedAt;1100 expect(after).toBeGreaterThan(before);1101 spy.mockRestore();1102 });11031104 it("deletedAt is strictly greater than the entry's modifiedAt", async () => {1105 const vault = await import("./vault");1106 await vault.createVault("monotonic-pass-bbbbb");1107 const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });1108 const modAt = vault.getEntryById(e.id)!.modifiedAt;1109 await vault.deleteEntry(e.id);1110 const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;1111 expect(tomb.deletedAt!).toBeGreaterThan(modAt);1112 });11131114 it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {1115 const vault = await import("./vault");1116 await vault.createVault("rollback-pass-aaaaa");1117 const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });11181119 const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1120 1121 setSpy.mockImplementation(async (next: Record<string, unknown>) => {1122 if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1123 Object.assign(storage, next);1124 });11251126 await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();11271128 setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1129 1130 expect(vault.getEntryById(e.id)!.password).toBe("original");1131 });11321133 it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1134 const vault = await import("./vault");1135 await vault.createVault("rootkey-pass-aaaaa");1136 const before = vault.getRootKeyHex();1137 expect(before).toMatch(/^[0-9a-f]{64}$/);11381139 expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1140 expect(vault.getRootKeyHex()).toBe(before);11411142 1143 vault.lock();1144 expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1145 expect(vault.getRootKeyHex()).toBe(before);1146 });11471148 it("getRootKeyHex returns null when the vault is locked", async () => {1149 const vault = await import("./vault");1150 await vault.createVault("rootkey-pass-ccccc");1151 vault.lock();1152 expect(vault.getRootKeyHex()).toBeNull();1153 });11541155 it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1156 const vault = await import("./vault");1157 await vault.createVault("biometric-pass-12345");1158 const rootBefore = vault.getRootKeyHex();1159 const keyHex = vault.getDerivedKeyHex();1160 expect(rootBefore).not.toBeNull();1161 vault.lock();1162 1163 const r = await vault.unlockWithKey(keyHex);1164 expect(r.ok).toBe(true);1165 expect(vault.getRootKeyHex()).toBe(rootBefore);1166 });11671168 it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1169 const vault = await import("./vault");1170 await vault.createVault("duress-real-pass-aaaa");1171 await vault.setupDuress("duress-decoy-pass-bbbb");11721173 const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1174 const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;11751176 1177 expect(lenA()).toBe(lenB());11781179 1180 1181 await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1182 await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1183 expect(lenA()).toBe(lenB());1184 });11851186 it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1187 const vault = await import("./vault");1188 await vault.createVault("rollback-pass-bbbbb");1189 const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });11901191 const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1192 setSpy.mockImplementation(async (next: Record<string, unknown>) => {1193 if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1194 Object.assign(storage, next);1195 });11961197 await expect(vault.deleteEntry(e.id)).rejects.toThrow();11981199 setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1200 1201 expect(vault.getEntryById(e.id)).toBeDefined();1202 expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1203 });12041205 describe("password history", () => {1206 it("captures the old password on change, newest first", async () => {1207 const vault = await import("./vault");1208 await vault.createVault("history-pass-12345");1209 const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1210 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();12111212 await vault.updateEntry(e.id, { password: "second" });1213 await vault.updateEntry(e.id, { password: "third" });12141215 const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1216 expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1217 expect(vault.getEntryById(e.id)?.password).toBe("third");1218 });12191220 it("does not record history on a metadata-only update", async () => {1221 const vault = await import("./vault");1222 await vault.createVault("history-meta-12345");1223 const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });12241225 await vault.updateEntry(e.id, { title: "Renamed" });1226 await vault.updateEntry(e.id, { username: "newuser" });12271228 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1229 });12301231 it("records each history item with the age the password carried", async () => {1232 const vault = await import("./vault");1233 await vault.createVault("history-stamp-12345");1234 const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1235 const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1236 expect(firstChangedAt).toBeGreaterThan(0);12371238 await new Promise((resolve) => setTimeout(resolve, 2));1239 await vault.updateEntry(e.id, { password: "v2" });12401241 const hist = vault.getEntryById(e.id)!.passwordHistory!;1242 1243 expect(hist[0].changedAt).toBe(firstChangedAt);1244 });12451246 it("skips capture when re-saving the identical password", async () => {1247 const vault = await import("./vault");1248 await vault.createVault("history-noop-12345");1249 const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });12501251 await vault.updateEntry(e.id, { password: "same" });12521253 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1254 });12551256 it("does not capture an empty prior password (clear then set)", async () => {1257 const vault = await import("./vault");1258 await vault.createVault("history-empty-12345");1259 const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });12601261 await vault.updateEntry(e.id, { password: "now-has-one" });12621263 1264 expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1265 });12661267 it("restoring an old password re-records the swapped-out current one", async () => {1268 const vault = await import("./vault");1269 await vault.createVault("history-restore-12345");1270 const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1271 await vault.updateEntry(e.id, { password: "new" });12721273 1274 await vault.updateEntry(e.id, { password: "old" });12751276 const hist = vault.getEntryById(e.id)!.passwordHistory!;1277 expect(vault.getEntryById(e.id)?.password).toBe("old");1278 expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1279 });12801281 it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1282 const vault = await import("./vault");1283 await vault.createVault("history-persist-12345");1284 const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1285 await vault.updateEntry(e.id, { password: "p2" });12861287 vault.lock();1288 expect(await vault.unlock("history-persist-12345")).toBe(true);12891290 const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1291 expect(hist.map((h) => h.password)).toEqual(["p1"]);1292 });12931294 it("caps history at 50 entries, dropping the oldest", async () => {1295 const vault = await import("./vault");1296 await vault.createVault("history-cap-12345");1297 const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });12981299 for (let i = 1; i <= 55; i++) {1300 await vault.updateEntry(e.id, { password: `pw-${i}` });1301 }13021303 const hist = vault.getEntryById(e.id)!.passwordHistory!;1304 expect(hist).toHaveLength(50);1305 1306 expect(hist[0].password).toBe("pw-54");1307 expect(hist[hist.length - 1].password).toBe("pw-5");1308 });1309 });13101311 describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1312 it("drops unknown/attacker keys from a restored entry", async () => {1313 const vault = await import("./vault");1314 await vault.createVault("correct horse battery staple");1315 await vault.restoreEntries(1316 [1317 {1318 id: "e1",1319 type: "login",1320 title: "Bank",1321 username: "june",1322 password: "pw",1323 createdAt: 1,1324 modifiedAt: 2,1325 evil: "payload",1326 isAdmin: true,1327 nested: { junk: 1 },1328 } as unknown as import("../shared/types").VaultEntry,1329 ],1330 "replace",1331 );1332 const restored = vault.getEntries().find((e) => e.id === "e1")!;1333 expect(restored.password).toBe("pw");1334 expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1335 expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1336 expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1337 });13381339 it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1340 const vault = await import("./vault");1341 await vault.createVault("correct horse battery staple");1342 await vault.restoreEntries(1343 [1344 {1345 id: "e2",1346 type: "login",1347 title: "Bank",1348 password: "pw",1349 createdAt: 1,1350 modifiedAt: 2,1351 canary: { webhookUrl: "http://attacker.example.com/x" },1352 } as unknown as import("../shared/types").VaultEntry,1353 ],1354 "replace",1355 );1356 const restored = vault.getEntries().find((e) => e.id === "e2")!;1357 expect(restored).toBeDefined();1358 expect(restored.canary).toBeUndefined();1359 });13601361 it("preserves a well-formed HTTPS canary webhook", async () => {1362 const vault = await import("./vault");1363 await vault.createVault("correct horse battery staple");1364 await vault.restoreEntries(1365 [1366 {1367 id: "e3",1368 type: "login",1369 title: "Bank",1370 password: "pw",1371 createdAt: 1,1372 modifiedAt: 2,1373 canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1374 } as unknown as import("../shared/types").VaultEntry,1375 ],1376 "replace",1377 );1378 const restored = vault.getEntries().find((e) => e.id === "e3")!;1379 expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1380 expect(restored.canary?.label).toBe("watch");1381 });13821383 it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1384 const vault = await import("./vault");1385 await vault.createVault("correct horse battery staple");1386 await vault.restoreEntries(1387 [1388 {1389 id: "e4",1390 type: "login",1391 title: "Has bad passkey",1392 createdAt: 1,1393 modifiedAt: 2,1394 passkey: { rpId: "example.com", publicKeyCose: "x" },1395 } as unknown as import("../shared/types").VaultEntry,1396 ],1397 "replace",1398 );1399 expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1400 });14011402 });14031404 describe("MV-8 rotateSyncIdentity", () => {1405 it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1406 const vault = await import("./vault");1407 await vault.createVault("correct horse battery staple");1408 const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1409 const { upsertPairedDevice, listPairedDevices } = await import(1410 "./paired-devices"1411 );1412 await upsertPairedDevice({1413 deviceId: "d1",1414 deviceName: "Phone",1415 deviceType: "android",1416 longTermPublicKeyHex: "a".repeat(64),1417 signingPublicKeyHex: "b".repeat(64),1418 pairedAt: 1,1419 trustSource: "direct",1420 });14211422 const rootBefore = vault.getRootKeyHex();1423 const res = await vault.rotateSyncIdentity();1424 expect(res.ok).toBe(true);1425 expect(vault.getRootKeyHex()).not.toBe(rootBefore);1426 expect(await listPairedDevices()).toHaveLength(0);1427 1428 expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");14291430 vault.lock();1431 expect(await vault.unlock("correct horse battery staple")).toBe(true);1432 expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1433 });14341435 it("invalidates the old recovery words and flags recovery-pending", async () => {1436 const vault = await import("./vault");1437 await vault.createVault("correct horse battery staple");1438 const wordsBefore = (await vault.getRecoveryWords()).join(" ");14391440 await vault.rotateSyncIdentity();14411442 const wordsAfter = (await vault.getRecoveryWords()).join(" ");1443 expect(wordsAfter).not.toBe(wordsBefore);1444 const { isRecoveryConfirmPending } = await import("./recovery-pending");1445 expect(await isRecoveryConfirmPending()).toBe(true);1446 });14471448 it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1449 const vault = await import("./vault");1450 await vault.createVault("correct horse battery staple");1451 const { createSyncVaultAdapter } = await import(1452 "./sync-vault-adapter"1453 );1454 const adapter = createSyncVaultAdapter();1455 const vidBefore = await adapter.computeVaultId();14561457 await vault.rotateSyncIdentity();14581459 const vidAfter = await adapter.computeVaultId();1460 expect(vidAfter).not.toBe(vidBefore);1461 });1462 });14631464 describe("LV-1 NFC master password normalization", () => {1465 const CAFE_BASE = "café";1466 const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1467 const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;14681469 it("a vault created with the NFD form unlocks with the NFC form", async () => {1470 const vault = await import("./vault");1471 expect(PW_NFD).not.toBe(PW_NFC);1472 await vault.createVault(PW_NFD);1473 vault.lock();1474 expect(await vault.unlock(PW_NFC)).toBe(true);1475 });14761477 });14781479 describe("LV-7 updateSettings proto-pollution filter", () => {1480 it("strips __proto__/constructor/prototype keys while persisting real settings", async () => {1481 const vault = await import("./vault");1482 await vault.createVault("correct horse battery staple");14831484 await vault.updateSettings({1485 theme: "dark",1486 ["__proto__"]: { polluted: true },1487 constructor: { polluted: true },1488 } as never);14891490 expect(({} as Record<string, unknown>).polluted).toBeUndefined();1491 expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();1492 expect(vault.getSettings().theme).toBe("dark");1493 });1494 });14951496 1497 1498 1499 1500 describe("PB-26-011 url scheme allow-list", () => {1501 let vault: typeof import("./vault");15021503 beforeEach(async () => {1504 vault = await import("./vault");1505 await vault.createVault("correct horse battery staple");1506 });15071508 it("drops a javascript: url on addEntry", async () => {1509 const entry = await vault.addEntry({1510 title: "Evil",1511 username: "june",1512 password: "secret1",1513 url: "javascript:alert(1)",1514 });1515 const stored = vault.getEntryById(entry.id);1516 expect(stored?.url).toBe("");1517 });15181519 it("drops non-http(s) schemes from additionalUrls", async () => {1520 const entry = await vault.addEntry({1521 title: "Evil",1522 username: "june",1523 password: "secret1",1524 url: "https://ok.test",1525 additionalUrls: [1526 "javascript:alert(1)",1527 "data:text/html,<b>x</b>",1528 "https://also-ok.test",1529 ],1530 });1531 const stored = vault.getEntryById(entry.id);1532 expect(stored?.additionalUrls).toEqual(["https://also-ok.test"]);1533 });15341535 it("drops a data: url on UPDATE_ENTRY patch", async () => {1536 const entry = await vault.addEntry({1537 title: "Ok",1538 username: "june",1539 password: "secret1",1540 url: "https://ok.test",1541 });1542 await vault.updateEntry(entry.id, { url: "data:text/html,<b>x</b>" });1543 const stored = vault.getEntryById(entry.id);1544 expect(stored?.url).toBe("");1545 });15461547 it("keeps https and schemeless urls", async () => {1548 const entry = await vault.addEntry({1549 title: "Ok",1550 username: "june",1551 password: "secret1",1552 url: "https://github.com",1553 additionalUrls: ["github.com"],1554 });1555 const stored = vault.getEntryById(entry.id);1556 expect(stored?.url).toBe("https://github.com");1557 expect(stored?.additionalUrls).toEqual(["github.com"]);1558 });1559 });1560});