// T-016: rotateSyncIdentity — new root key, cleared peers, recovery pending // // Why this test exists: rotateSyncIdentity (MV-8) is the "nuclear option" for // a lost/stolen device — it swaps in a new root key so the compromised device's // retained key material becomes useless. It also: // - Clears all paired devices (they must re-pair) // - Sets session-owned recovery-pending state (new recovery words must be acknowledged) // - Rewraps the new root key under the SAME password-derived key (so the // vault stays unlockable with the current password) // - Does NOT re-encrypt existing entries (they are password-sealed, not // root-key-sealed) // // This function was added in MV-8 and has ZERO test coverage in the existing // suite. A regression here would silently fail to rotate the root key or // silently fail to clear paired devices — leaving the user's vault unprotected // while the UI presents the rotation as successful. import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; // --------------------------------------------------------------------------- // Chrome storage mock (same pattern as messages.batch-sanitize.test.ts) // --------------------------------------------------------------------------- function makeChromeStorageMock(): { storage: StorageShape; chrome: typeof globalThis.chrome; } { const storage: StorageShape = {}; const sessionStorage: StorageShape = {}; const chrome = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return { ...storage }; if (typeof key === "string") return { [key]: storage[key] }; return (Array.isArray(key) ? key : [key]).reduce( (acc, k) => { acc[k] = storage[k]; return acc; }, {}, ); }), set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const ks = Array.isArray(keys) ? keys : [keys]; for (const k of ks) delete storage[k]; }), clear: vi.fn(async () => { for (const k of Object.keys(storage)) delete storage[k]; }), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return { ...sessionStorage }; if (typeof key === "string") return { [key]: sessionStorage[key] }; return key.reduce((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {}); }), set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async (keys: string | string[]) => { for (const key of (Array.isArray(keys) ? keys : [keys])) delete sessionStorage[key]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, } as unknown as typeof globalThis.chrome; return { storage, chrome }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("T-016: rotateSyncIdentity — MV-8 rotation invariants", () => { let storage: StorageShape; let vault: typeof import("./vault"); const VAULT_PASSWORD = "rotate-test-pass-007"; beforeEach(async () => { vi.resetModules(); const mock = makeChromeStorageMock(); storage = mock.storage; (globalThis as Record).chrome = mock.chrome; vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); })); vault = await import("./vault"); await vault.createVault(VAULT_PASSWORD); }); // T-016a: rotateSyncIdentity returns { ok: true } while vault is unlocked. // Bug this catches: the function returns early with { ok: false, error: "vault_locked" } // if the vault is not in the "real" mode — testing confirms the vault is // actually unlocked when we call the function. it("T-016a: rotateSyncIdentity returns { ok: true } while vault is unlocked", async () => { const result = await vault.rotateSyncIdentity(); expect(result.ok).toBe(true); expect(result.error).toBeUndefined(); }); // T-016b: root key changes after rotation. // getRootKeyHex() exposes the in-memory root key. Before and after rotation // the hex strings must differ — confirming a genuinely new key was generated // (not a no-op that re-uses the same key material). // // Bug this catches: a rotation that calls generateAndWrapRootKey() but // forgets to swap rootKey in memory (step 4 in rotateSyncIdentity: "Swap // in memory only after disk committed") would leave getRootKeyHex() unchanged. it("T-016b: getRootKeyHex differs before and after rotation (new key generated)", async () => { const rootKeyBefore = vault.getRootKeyHex(); expect(rootKeyBefore).not.toBeNull(); await vault.rotateSyncIdentity(); const rootKeyAfter = vault.getRootKeyHex(); expect(rootKeyAfter).not.toBeNull(); expect(rootKeyAfter).not.toBe(rootKeyBefore); }); // T-016c: paired devices are cleared after rotation. // After rotation, the old paired devices must no longer appear in storage — // their Noise static keys are derived from the old root key and cannot // be used to authenticate against the new sync identity. // // Bug this catches: forgetting to call clearPairedDevices() after rotation // would leave stale device entries; the sync layer would attempt to use them // and fail in a confusing way (wrong MAC on decrypted handshake). it("T-016c: paired devices cleared from storage after rotation", async () => { // Pre-populate a fake paired device entry const PAIRED_KEY = "peach_paired_devices_v3"; storage[PAIRED_KEY] = [ { deviceId: "test-device-1", publicKey: "deadbeef".repeat(8), label: "My Laptop", pairedAt: 0, }, ]; await vault.rotateSyncIdentity(); // The paired devices key must be absent (or empty) in storage const devicesAfter = storage[PAIRED_KEY]; const isEmpty = devicesAfter === undefined || devicesAfter === null || (Array.isArray(devicesAfter) && (devicesAfter as unknown[]).length === 0); expect(isEmpty).toBe(true); }); // T-016d: the session-owned recovery-pending state is set after rotation. // Rotation generates new 12 recovery words (derived from the new root key). // The user MUST re-confirm these words before the old ones become the only // backup. The recovery_pending flag routes the popup back to the word display. // // Bug this catches: forgetting setRecoveryConfirmPending() after rotation // means the user is never prompted to re-confirm new recovery words — they // continue thinking their old words are valid when they are not. it("T-016d: session recovery-pending state is set after rotation", async () => { await vault.rotateSyncIdentity(); const { isRecoveryConfirmPending } = await import("./recovery-pending"); await expect(isRecoveryConfirmPending()).resolves.toBe(true); expect(storage["peach_recovery_pending"]).toBeUndefined(); }); // T-016e: vault is still unlockable with the same password after rotation. // The new root key is re-wrapped under the password-derived key — the user's // passphrase still works. This is the most critical invariant: a rotation // that breaks the password unlock would lock the user out of their vault. // // Bug this catches: a rotation that fails to persist the new root key wrapped // under the current password (step 2 in rotateSyncIdentity: "Re-wrap the new // root key under the current password-derived key") would make the vault // inaccessible after the next browser restart. it("T-016e: vault still unlocks with the same password after rotation", async () => { await vault.rotateSyncIdentity(); // Lock the vault (clears in-memory derived key and root key) vault.lock(); expect(vault.isUnlocked()).toBe(false); // Unlock with the same password — must succeed const unlocked = await vault.unlock(VAULT_PASSWORD); expect(unlocked).toBe(true); expect(vault.isUnlocked()).toBe(true); }); // T-016f: entries are preserved after rotation. // Entries are encrypted under the password-derived key, NOT the root key. // Rotation changes the root key but leaves entry ciphertext untouched. // After rotation and re-lock, existing entries must still be readable. it("T-016f: entries encrypted before rotation are still accessible after", async () => { // Add an entry before rotation const added = await vault.addEntry({ type: "login", title: "Pre-Rotation Entry", username: "alice", password: "s3cret", url: "https://example.com", tags: [], }); expect(added.id).toBeTruthy(); await vault.rotateSyncIdentity(); // Lock and re-unlock vault.lock(); await vault.unlock(VAULT_PASSWORD); // Entry must still be present const entries = vault.getEntries(); const found = entries.find((e) => e.id === added.id); expect(found).toBeDefined(); expect(found?.title).toBe("Pre-Rotation Entry"); }); // T-016g: rotateSyncIdentity returns { ok: false, error: "vault_locked" } // when called while the vault is locked. No partial state change must occur. it("T-016g: rotateSyncIdentity fails gracefully when vault is locked", async () => { vault.lock(); const result = await vault.rotateSyncIdentity(); expect(result.ok).toBe(false); expect(result.error).toBe("vault_locked"); }); });