/** * Tests for client-side encryption utilities (src/lib/crypto.ts) * * Uses a self-consistent XOR-based sodium mock so tests run without * WebAssembly (libsodium) in the Node/happy-dom environment. * The real PBKDF2 key derivation (Web Crypto API) is tested as-is. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { deriveSecretKey, generateDeviceKey, encryptEntry, decryptEntry, encryptSecretKey, decryptSecretKey, generateSalt, getSalt, saveSalt, saveDeviceKey, getDeviceKey, clearDeviceKey, } from '../lib/crypto' // --------------------------------------------------------------------------- // Self-consistent sodium mock (XOR cipher + 16-byte auth tag) // encrypt(msg, key) = tag(16 zeros) || xor(msg, key) // decrypt(ct, key) = xor(ct[16:], key) // Wrong-key behavior is tested by overriding crypto_secretbox_open_easy to throw. // --------------------------------------------------------------------------- function xorBytes(data: Uint8Array, key: Uint8Array): Uint8Array { return data.map((byte, i) => byte ^ key[i % key.length]) } const createMockSodium = (overrides: Record = {}) => ({ randombytes_buf: (size: number) => new Uint8Array(size).fill(42), crypto_secretbox_NONCEBYTES: 24, crypto_secretbox_easy: (msg: Uint8Array, _nonce: Uint8Array, key: Uint8Array) => { const tag = new Uint8Array(16) const encrypted = xorBytes(msg, key) const result = new Uint8Array(tag.length + encrypted.length) result.set(tag) result.set(encrypted, tag.length) return result }, crypto_secretbox_open_easy: (ct: Uint8Array, _nonce: Uint8Array, key: Uint8Array) => { if (ct.length < 16) throw new Error('invalid ciphertext length') return xorBytes(ct.slice(16), key) }, to_base64: (data: Uint8Array) => Buffer.from(data).toString('base64'), from_base64: (str: string) => new Uint8Array(Buffer.from(str, 'base64')), from_string: (str: string) => new TextEncoder().encode(str), to_string: (data: Uint8Array) => new TextDecoder().decode(data), ...overrides, }) vi.mock('../utils/sodium', () => ({ getSodium: vi.fn(), })) // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('crypto utilities', () => { beforeEach(async () => { const { getSodium } = await import('../utils/sodium') vi.mocked(getSodium).mockResolvedValue(createMockSodium() as never) localStorage.clear() }) // ── deriveSecretKey ────────────────────────────────────────────────────── describe('deriveSecretKey', () => { it('returns a 32-byte Uint8Array', async () => { const key = await deriveSecretKey('test-uid-123', 'test-salt') expect(key).toBeInstanceOf(Uint8Array) expect(key.length).toBe(32) }) it('is deterministic — same inputs always produce the same key', async () => { const key1 = await deriveSecretKey('uid-abc', 'salt-xyz') const key2 = await deriveSecretKey('uid-abc', 'salt-xyz') expect(key1).toEqual(key2) }) it('different UIDs produce different keys', async () => { const key1 = await deriveSecretKey('uid-1', 'same-salt') const key2 = await deriveSecretKey('uid-2', 'same-salt') expect(key1).not.toEqual(key2) }) it('different salts produce different keys', async () => { const key1 = await deriveSecretKey('same-uid', 'salt-a') const key2 = await deriveSecretKey('same-uid', 'salt-b') expect(key1).not.toEqual(key2) }) it('handles empty UID string', async () => { const key = await deriveSecretKey('', 'some-salt') expect(key).toBeInstanceOf(Uint8Array) expect(key.length).toBe(32) }) }) // ── generateDeviceKey ──────────────────────────────────────────────────── describe('generateDeviceKey', () => { it('returns a 32-byte Uint8Array', async () => { const key = await generateDeviceKey() expect(key).toBeInstanceOf(Uint8Array) expect(key.length).toBe(32) }) it('generates unique keys each time (random)', async () => { const key1 = await generateDeviceKey() const key2 = await generateDeviceKey() // Two random 256-bit arrays should be different expect(key1).not.toEqual(key2) }) }) // ── encryptEntry / decryptEntry ────────────────────────────────────────── describe('encryptEntry / decryptEntry', () => { const secretKey = new Uint8Array(32).fill(1) it('roundtrip: decrypting an encrypted entry returns original content', async () => { const content = 'Today I am grateful for my family.' const { ciphertext, nonce } = await encryptEntry(content, secretKey) const decrypted = await decryptEntry(ciphertext, nonce, secretKey) expect(decrypted).toBe(content) }) it('returns base64-encoded strings for ciphertext and nonce', async () => { const { ciphertext, nonce } = await encryptEntry('test content', secretKey) expect(() => Buffer.from(ciphertext, 'base64')).not.toThrow() expect(() => Buffer.from(nonce, 'base64')).not.toThrow() // Valid base64 only contains these characters expect(ciphertext).toMatch(/^[A-Za-z0-9+/=]+$/) }) it('handles empty string content', async () => { const { ciphertext, nonce } = await encryptEntry('', secretKey) const decrypted = await decryptEntry(ciphertext, nonce, secretKey) expect(decrypted).toBe('') }) it('handles unicode and emoji content', async () => { const content = 'Grateful for 🌟 life! नमस्ते 日本語' const { ciphertext, nonce } = await encryptEntry(content, secretKey) const decrypted = await decryptEntry(ciphertext, nonce, secretKey) expect(decrypted).toBe(content) }) it('handles very long content (10,000 chars)', async () => { const content = 'a'.repeat(10000) const { ciphertext, nonce } = await encryptEntry(content, secretKey) const decrypted = await decryptEntry(ciphertext, nonce, secretKey) expect(decrypted).toBe(content) }) it('different plaintext produces different ciphertext', async () => { const { ciphertext: ct1 } = await encryptEntry('hello world', secretKey) const { ciphertext: ct2 } = await encryptEntry('goodbye world', secretKey) expect(ct1).not.toBe(ct2) }) it('decryptEntry throws "Failed to decrypt entry" on bad ciphertext', async () => { const { getSodium } = await import('../utils/sodium') vi.mocked(getSodium).mockResolvedValueOnce(createMockSodium({ crypto_secretbox_open_easy: () => { throw new Error('invalid mac') }, }) as never) await expect(decryptEntry('notvalidbase64!!', 'nonce', secretKey)) .rejects.toThrow('Failed to decrypt entry') }) it('decryptEntry throws when called with wrong key', async () => { // Simulate libsodium authentication failure with wrong key const { getSodium } = await import('../utils/sodium') vi.mocked(getSodium) .mockResolvedValueOnce(createMockSodium() as never) // for encrypt .mockResolvedValueOnce(createMockSodium({ // for decrypt (wrong key throws) crypto_secretbox_open_easy: () => { throw new Error('incorrect key') }, }) as never) const { ciphertext, nonce } = await encryptEntry('secret', secretKey) const wrongKey = new Uint8Array(32).fill(99) await expect(decryptEntry(ciphertext, nonce, wrongKey)) .rejects.toThrow('Failed to decrypt entry') }) }) // ── encryptSecretKey / decryptSecretKey ────────────────────────────────── describe('encryptSecretKey / decryptSecretKey', () => { it('roundtrip: encrypts and decrypts master key back to original', async () => { const masterKey = new Uint8Array(32).fill(99) const deviceKey = new Uint8Array(32).fill(55) const { ciphertext, nonce } = await encryptSecretKey(masterKey, deviceKey) const decrypted = await decryptSecretKey(ciphertext, nonce, deviceKey) expect(decrypted).toEqual(masterKey) }) it('returns base64 strings', async () => { const masterKey = new Uint8Array(32).fill(1) const deviceKey = new Uint8Array(32).fill(2) const { ciphertext, nonce } = await encryptSecretKey(masterKey, deviceKey) expect(typeof ciphertext).toBe('string') expect(typeof nonce).toBe('string') }) it('decryptSecretKey throws "Failed to decrypt secret key" on wrong device key', async () => { const { getSodium } = await import('../utils/sodium') vi.mocked(getSodium).mockResolvedValueOnce(createMockSodium({ crypto_secretbox_open_easy: () => { throw new Error('decryption failed') }, }) as never) await expect(decryptSecretKey('fakeciphertext', 'fakenonce', new Uint8Array(32))) .rejects.toThrow('Failed to decrypt secret key') }) }) // ── salt functions ─────────────────────────────────────────────────────── describe('generateSalt / saveSalt / getSalt', () => { it('generateSalt returns the constant salt string', () => { expect(generateSalt()).toBe('grateful-journal-v1') }) it('generateSalt is idempotent', () => { expect(generateSalt()).toBe(generateSalt()) }) it('saveSalt and getSalt roundtrip', () => { saveSalt('my-custom-salt') expect(getSalt()).toBe('my-custom-salt') }) it('getSalt returns null when nothing stored', () => { localStorage.clear() expect(getSalt()).toBeNull() }) it('overwriting salt replaces old value', () => { saveSalt('first') saveSalt('second') expect(getSalt()).toBe('second') }) }) // ── device key localStorage ────────────────────────────────────────────── describe('saveDeviceKey / getDeviceKey / clearDeviceKey', () => { it('saves and retrieves device key correctly', async () => { const key = new Uint8Array(32).fill(7) await saveDeviceKey(key) const retrieved = await getDeviceKey() expect(retrieved).toEqual(key) }) it('returns null when no device key is stored', async () => { localStorage.clear() const key = await getDeviceKey() expect(key).toBeNull() }) it('clearDeviceKey removes the stored key', async () => { const key = new Uint8Array(32).fill(7) await saveDeviceKey(key) clearDeviceKey() const retrieved = await getDeviceKey() expect(retrieved).toBeNull() }) it('overwriting device key stores the new key', async () => { const key1 = new Uint8Array(32).fill(1) const key2 = new Uint8Array(32).fill(2) await saveDeviceKey(key1) await saveDeviceKey(key2) const retrieved = await getDeviceKey() expect(retrieved).toEqual(key2) }) }) })