vault.rotate-sync-identity.test.ts
1// T-016: rotateSyncIdentity — new root key, cleared peers, recovery pending2//3// Why this test exists: rotateSyncIdentity (MV-8) is the "nuclear option" for4// a lost/stolen device — it swaps in a new root key so the compromised device's5// retained key material becomes useless. It also:6// - Clears all paired devices (they must re-pair)7// - Sets peach_recovery_pending (new recovery words must be acknowledged)8// - Rewraps the new root key under the SAME password-derived key (so the9// vault stays unlockable with the current password)10// - Does NOT re-encrypt existing entries (they are password-sealed, not11// root-key-sealed)12//13// This function was added in MV-8 and has ZERO test coverage in the existing14// suite. A regression here would silently fail to rotate the root key or15// silently fail to clear paired devices — leaving the user's vault unprotected16// while the UI presents the rotation as successful.1718import { beforeEach, describe, expect, it, vi } from "vitest";1920type StorageShape = Record<string, unknown>;2122// ---------------------------------------------------------------------------23// Chrome storage mock (same pattern as messages.batch-sanitize.test.ts)24// ---------------------------------------------------------------------------2526function makeChromeStorageMock(): {27 storage: StorageShape;28 chrome: typeof globalThis.chrome;29} {30 const storage: StorageShape = {};31 const chrome = {32 storage: {33 local: {34 get: vi.fn(async (key?: string | string[]) => {35 if (!key) return { ...storage };36 if (typeof key === "string") return { [key]: storage[key] };37 return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(38 (acc, k) => { acc[k] = storage[k]; return acc; },39 {},40 );41 }),42 set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),43 remove: vi.fn(async (keys: string | string[]) => {44 const ks = Array.isArray(keys) ? keys : [keys];45 for (const k of ks) delete storage[k];46 }),47 clear: vi.fn(async () => {48 for (const k of Object.keys(storage)) delete storage[k];49 }),50 },51 session: {52 get: vi.fn(async () => ({})),53 set: vi.fn(async () => {}),54 remove: vi.fn(async () => {}),55 },56 },57 alarms: {58 clear: vi.fn(),59 create: vi.fn(),60 onAlarm: { addListener: vi.fn() },61 },62 idle: {63 setDetectionInterval: vi.fn(),64 onStateChanged: { addListener: vi.fn() },65 },66 } as unknown as typeof globalThis.chrome;67 return { storage, chrome };68}6970// ---------------------------------------------------------------------------71// Tests72// ---------------------------------------------------------------------------7374describe("T-016: rotateSyncIdentity — MV-8 rotation invariants", () => {75 let storage: StorageShape;76 let vault: typeof import("./vault");77 const VAULT_PASSWORD = "rotate-test-pass-007";7879 beforeEach(async () => {80 vi.resetModules();81 const mock = makeChromeStorageMock();82 storage = mock.storage;83 (globalThis as Record<string, unknown>).chrome = mock.chrome;84 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));8586 vault = await import("./vault");87 await vault.createVault(VAULT_PASSWORD);88 });8990 // T-016a: rotateSyncIdentity returns { ok: true } while vault is unlocked.91 // Bug this catches: the function returns early with { ok: false, error: "vault_locked" }92 // if the vault is not in the "real" mode — testing confirms the vault is93 // actually unlocked when we call the function.94 it("T-016a: rotateSyncIdentity returns { ok: true } while vault is unlocked", async () => {95 const result = await vault.rotateSyncIdentity();96 expect(result.ok).toBe(true);97 expect(result.error).toBeUndefined();98 });99100 // T-016b: root key changes after rotation.101 // getRootKeyHex() exposes the in-memory root key. Before and after rotation102 // the hex strings must differ — confirming a genuinely new key was generated103 // (not a no-op that re-uses the same key material).104 //105 // Bug this catches: a rotation that calls generateAndWrapRootKey() but106 // forgets to swap rootKey in memory (step 4 in rotateSyncIdentity: "Swap107 // in memory only after disk committed") would leave getRootKeyHex() unchanged.108 it("T-016b: getRootKeyHex differs before and after rotation (new key generated)", async () => {109 const rootKeyBefore = vault.getRootKeyHex();110 expect(rootKeyBefore).not.toBeNull();111112 await vault.rotateSyncIdentity();113114 const rootKeyAfter = vault.getRootKeyHex();115 expect(rootKeyAfter).not.toBeNull();116 expect(rootKeyAfter).not.toBe(rootKeyBefore);117 });118119 // T-016c: paired devices are cleared after rotation.120 // After rotation, the old paired devices must no longer appear in storage —121 // their Noise static keys are derived from the old root key and cannot122 // be used to authenticate against the new sync identity.123 //124 // Bug this catches: forgetting to call clearPairedDevices() after rotation125 // would leave stale device entries; the sync layer would attempt to use them126 // and fail in a confusing way (wrong MAC on decrypted handshake).127 it("T-016c: paired devices cleared from storage after rotation", async () => {128 // Pre-populate a fake paired device entry129 const PAIRED_KEY = "peach_paired_devices_v3";130 storage[PAIRED_KEY] = [131 {132 deviceId: "test-device-1",133 publicKey: "deadbeef".repeat(8),134 label: "My Laptop",135 pairedAt: 0,136 },137 ];138139 await vault.rotateSyncIdentity();140141 // The paired devices key must be absent (or empty) in storage142 const devicesAfter = storage[PAIRED_KEY];143 const isEmpty =144 devicesAfter === undefined ||145 devicesAfter === null ||146 (Array.isArray(devicesAfter) && (devicesAfter as unknown[]).length === 0);147 expect(isEmpty).toBe(true);148 });149150 // T-016d: peach_recovery_pending is set to "1" after rotation.151 // Rotation generates new 12 recovery words (derived from the new root key).152 // The user MUST re-confirm these words before the old ones become the only153 // backup. The recovery_pending flag routes the popup back to the word display.154 //155 // Bug this catches: forgetting setRecoveryConfirmPending() after rotation156 // means the user is never prompted to re-confirm new recovery words — they157 // continue thinking their old words are valid when they are not.158 it("T-016d: peach_recovery_pending set to '1' after rotation", async () => {159 await vault.rotateSyncIdentity();160161 expect(storage["peach_recovery_pending"]).toBe("1");162 });163164 // T-016e: vault is still unlockable with the same password after rotation.165 // The new root key is re-wrapped under the password-derived key — the user's166 // passphrase still works. This is the most critical invariant: a rotation167 // that breaks the password unlock would lock the user out of their vault.168 //169 // Bug this catches: a rotation that fails to persist the new root key wrapped170 // under the current password (step 2 in rotateSyncIdentity: "Re-wrap the new171 // root key under the current password-derived key") would make the vault172 // inaccessible after the next browser restart.173 it("T-016e: vault still unlocks with the same password after rotation", async () => {174 await vault.rotateSyncIdentity();175176 // Lock the vault (clears in-memory derived key and root key)177 vault.lock();178 expect(vault.isUnlocked()).toBe(false);179180 // Unlock with the same password — must succeed181 const unlocked = await vault.unlock(VAULT_PASSWORD);182 expect(unlocked).toBe(true);183 expect(vault.isUnlocked()).toBe(true);184 });185186 // T-016f: entries are preserved after rotation.187 // Entries are encrypted under the password-derived key, NOT the root key.188 // Rotation changes the root key but leaves entry ciphertext untouched.189 // After rotation and re-lock, existing entries must still be readable.190 it("T-016f: entries encrypted before rotation are still accessible after", async () => {191 // Add an entry before rotation192 const added = await vault.addEntry({193 type: "login",194 title: "Pre-Rotation Entry",195 username: "alice",196 password: "s3cret",197 url: "https://example.com",198 tags: [],199 });200 expect(added.id).toBeTruthy();201202 await vault.rotateSyncIdentity();203204 // Lock and re-unlock205 vault.lock();206 await vault.unlock(VAULT_PASSWORD);207208 // Entry must still be present209 const entries = vault.getEntries();210 const found = entries.find((e) => e.id === added.id);211 expect(found).toBeDefined();212 expect(found?.title).toBe("Pre-Rotation Entry");213 });214215 // T-016g: rotateSyncIdentity returns { ok: false, error: "vault_locked" }216 // when called while the vault is locked. No partial state change must occur.217 it("T-016g: rotateSyncIdentity fails gracefully when vault is locked", async () => {218 vault.lock();219 const result = await vault.rotateSyncIdentity();220 expect(result.ok).toBe(false);221 expect(result.error).toBe("vault_locked");222 });223});