import { describe, it, expect } from 'vitest'; import { encryptField, decryptField, generateKey, keyFromHex, keyToHex, deriveKey, } from './aes-gcm.js'; import { isEncryptedField } from './guards.js'; import { toHex, fromHex } from './hex.js'; describe('encryptField / decryptField', () => { it('roundtrip', async () => { const key = await generateKey(); const encrypted = await encryptField('Hello, World!', key, 'dek_test'); const decrypted = await decryptField(encrypted, key); expect(decrypted).toBe('Hello, World!'); }); it('empty string', async () => { const key = await generateKey(); const encrypted = await encryptField('', key, 'dek_test'); const decrypted = await decryptField(encrypted, key); expect(decrypted).toBe(''); }); it('unicode', async () => { const key = await generateKey(); const text = 'こんにちは世界 🌍 مرحبا Ñoño'; const encrypted = await encryptField(text, key, 'dek_test'); const decrypted = await decryptField(encrypted, key); expect(decrypted).toBe(text); }); it('large payload', async () => { const key = await generateKey(); const text = 'A'.repeat(100_000); const encrypted = await encryptField(text, key, 'dek_test'); const decrypted = await decryptField(encrypted, key); expect(decrypted).toBe(text); }); }); describe('EncryptedField structure', () => { it('has correct sentinel fields', async () => { const key = await generateKey(); const encrypted = await encryptField('test', key, 'dek_test'); expect(encrypted.__encrypted).toBe(true); expect(encrypted.v).toBe(1); expect(encrypted.alg).toBe('aes-256-gcm'); expect(encrypted.dekId).toBe('dek_test'); }); it('has correct hex lengths', async () => { const key = await generateKey(); const encrypted = await encryptField('test', key, 'dek_test'); expect(encrypted.iv.length).toBe(24); // 12 bytes = 24 hex expect(encrypted.tag.length).toBe(32); // 16 bytes = 32 hex expect(encrypted.ct.length).toBeGreaterThan(0); }); it('unique IVs per encryption', async () => { const key = await generateKey(); const a = await encryptField('same', key, 'dek_test'); const b = await encryptField('same', key, 'dek_test'); expect(a.iv).not.toBe(b.iv); expect(a.ct).not.toBe(b.ct); }); }); describe('AAD (Additional Authenticated Data)', () => { it('roundtrip with AAD', async () => { const key = await generateKey(); const encrypted = await encryptField('secret', key, 'dek_test', 'user:ctx'); const decrypted = await decryptField(encrypted, key, 'user:ctx'); expect(decrypted).toBe('secret'); }); it('wrong AAD fails', async () => { const key = await generateKey(); const encrypted = await encryptField('secret', key, 'dek_test', 'correct'); await expect(decryptField(encrypted, key, 'wrong')).rejects.toThrow(); }); it('missing AAD fails', async () => { const key = await generateKey(); const encrypted = await encryptField('secret', key, 'dek_test', 'some-aad'); await expect(decryptField(encrypted, key)).rejects.toThrow(); }); }); describe('wrong key', () => { it('decrypt with wrong key fails', async () => { const key = await generateKey(); const wrongKey = await generateKey(); const encrypted = await encryptField('secret', key, 'dek_test'); await expect(decryptField(encrypted, wrongKey)).rejects.toThrow(); }); }); describe('keyFromHex / keyToHex', () => { it('roundtrip', async () => { const key = await generateKey(); const hex = await keyToHex(key); expect(hex.length).toBe(64); // 32 bytes = 64 hex chars const restored = await keyFromHex(hex); const encrypted = await encryptField('test', key, 'dek_test'); const decrypted = await decryptField(encrypted, restored); expect(decrypted).toBe('test'); }); it('rejects invalid length', async () => { await expect(keyFromHex('aabb')).rejects.toThrow('32-byte key'); }); }); describe('deriveKey', () => { it('derives consistent key from passphrase + salt', async () => { const salt = new Uint8Array(16); globalThis.crypto.getRandomValues(salt); const key1 = await deriveKey('my-passphrase', salt, 1000, true); const key2 = await deriveKey('my-passphrase', salt, 1000, true); const hex1 = await keyToHex(key1); const hex2 = await keyToHex(key2); expect(hex1).toBe(hex2); }); it('different passphrases produce different keys', async () => { const salt = new Uint8Array(16); globalThis.crypto.getRandomValues(salt); const key1 = await deriveKey('pass-1', salt, 1000, true); const key2 = await deriveKey('pass-2', salt, 1000, true); const hex1 = await keyToHex(key1); const hex2 = await keyToHex(key2); expect(hex1).not.toBe(hex2); }); it('derived key can encrypt/decrypt', async () => { const salt = new Uint8Array(16); globalThis.crypto.getRandomValues(salt); const key = await deriveKey('test', salt, 1000, true); const encrypted = await encryptField('hello', key, 'dek_test'); const decrypted = await decryptField(encrypted, key); expect(decrypted).toBe('hello'); }); }); describe('isEncryptedField', () => { it('true for valid EncryptedField', async () => { const key = await generateKey(); const encrypted = await encryptField('test', key, 'dek_test'); expect(isEncryptedField(encrypted)).toBe(true); }); it('false for plain string', () => { expect(isEncryptedField('just a string')).toBe(false); }); it('false for null', () => { expect(isEncryptedField(null)).toBe(false); }); it('false for incomplete object', () => { expect(isEncryptedField({ __encrypted: true, v: 1 })).toBe(false); }); }); describe('hex utilities', () => { it('toHex / fromHex roundtrip', () => { const bytes = new Uint8Array([0x00, 0x0f, 0xff, 0xab, 0xcd]); const hex = toHex(bytes); expect(hex).toBe('000fffabcd'); const restored = fromHex(hex); expect(restored).toEqual(bytes); }); it('fromHex rejects odd length', () => { expect(() => fromHex('a')).toThrow('even length'); }); });