/** * @bytelyst/field-encrypt — Tests * * ~35 tests covering AES-GCM, key providers, envelope, cache, * field encryptor factory, type guards, and migration. */ import { describe, it, expect, beforeEach } from 'vitest'; import { randomBytes } from 'node:crypto'; import { createFieldEncryptor, FieldEncryptor, NullFieldEncryptor, isEncryptedField, encryptField, decryptField, generateAesKey, buildDekId, getOrCreateDek, rewrapAllDeks, DekCache, MemoryDekStore, CosmosDekStore, MemoryKeyProvider, EnvKeyProvider, migrateDocuments, } from './index.js'; import type { EncryptedField, FieldEncryptContext } from './types.js'; // ── AES-256-GCM ───────────────────────────────────── describe('aes-gcm', () => { const key = generateAesKey(); const dekId = 'dek_test_ctx'; it('encrypt → decrypt roundtrip', () => { const plaintext = 'Hello, sensitive data!'; const encrypted = encryptField(plaintext, key, dekId); const decrypted = decryptField(encrypted, key); expect(decrypted).toBe(plaintext); }); it('encrypt → decrypt with AAD', () => { const plaintext = 'With AAD'; const aad = 'user_123:transcripts'; const encrypted = encryptField(plaintext, key, dekId, aad); const decrypted = decryptField(encrypted, key, aad); expect(decrypted).toBe(plaintext); }); it('rejects decryption with wrong AAD', () => { const encrypted = encryptField('secret', key, dekId, 'correct_aad'); expect(() => decryptField(encrypted, key, 'wrong_aad')).toThrow(); }); it('rejects decryption with tampered ciphertext', () => { const encrypted = encryptField('secret', key, dekId); const tampered: EncryptedField = { ...encrypted, ct: 'deadbeef' }; expect(() => decryptField(tampered, key)).toThrow(); }); it('rejects decryption with tampered auth tag', () => { const encrypted = encryptField('secret', key, dekId); const tampered: EncryptedField = { ...encrypted, tag: '00'.repeat(16) }; expect(() => decryptField(tampered, key)).toThrow(); }); it('handles empty string', () => { const encrypted = encryptField('', key, dekId); const decrypted = decryptField(encrypted, key); expect(decrypted).toBe(''); }); it('handles unicode content', () => { const plaintext = '日本語テスト 🔐 Ñoño'; const encrypted = encryptField(plaintext, key, dekId); const decrypted = decryptField(encrypted, key); expect(decrypted).toBe(plaintext); }); it('handles large payload (100 KB)', () => { const plaintext = 'x'.repeat(100_000); const encrypted = encryptField(plaintext, key, dekId); const decrypted = decryptField(encrypted, key); expect(decrypted).toBe(plaintext); }); it('rejects wrong key size', () => { const shortKey = randomBytes(16); expect(() => encryptField('test', shortKey, dekId)).toThrow(/32-byte key/); }); it('produces correct EncryptedField shape', () => { const encrypted = encryptField('test', key, dekId); expect(encrypted.__encrypted).toBe(true); expect(encrypted.v).toBe(1); expect(encrypted.alg).toBe('aes-256-gcm'); expect(encrypted.dekId).toBe(dekId); expect(encrypted.iv).toHaveLength(24); // 12 bytes = 24 hex chars expect(encrypted.tag).toHaveLength(32); // 16 bytes = 32 hex chars expect(encrypted.ct.length).toBeGreaterThan(0); }); it('generates unique IVs per encryption', () => { const e1 = encryptField('same', key, dekId); const e2 = encryptField('same', key, dekId); expect(e1.iv).not.toBe(e2.iv); expect(e1.ct).not.toBe(e2.ct); }); }); // ── Type guard ────────────────────────────────────── describe('isEncryptedField', () => { it('returns true for valid EncryptedField', () => { const field: EncryptedField = { __encrypted: true, v: 1, alg: 'aes-256-gcm', ct: 'abc', iv: '012', tag: '345', dekId: 'dek_1', }; expect(isEncryptedField(field)).toBe(true); }); it('returns false for string', () => { expect(isEncryptedField('hello')).toBe(false); }); it('returns false for null', () => { expect(isEncryptedField(null)).toBe(false); }); it('returns false for undefined', () => { expect(isEncryptedField(undefined)).toBe(false); }); it('returns false for object without __encrypted', () => { expect(isEncryptedField({ v: 1, ct: 'abc' })).toBe(false); }); it('returns false for object with __encrypted: false', () => { expect( isEncryptedField({ __encrypted: false, v: 1, ct: 'a', iv: 'b', tag: 'c', dekId: 'd' }) ).toBe(false); }); }); // ── Key providers ─────────────────────────────────── describe('MemoryKeyProvider', () => { it('wrap → unwrap roundtrip', async () => { const provider = new MemoryKeyProvider(); const dek = generateAesKey(); const { wrappedKey, mekVersion } = await provider.wrapKey(dek); const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); expect(unwrapped).toEqual(dek); }); }); describe('EnvKeyProvider', () => { it('wrap → unwrap roundtrip with 64-char hex key', async () => { const hexKey = randomBytes(32).toString('hex'); const provider = new EnvKeyProvider(hexKey); const dek = generateAesKey(); const { wrappedKey, mekVersion } = await provider.wrapKey(dek); const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); expect(unwrapped).toEqual(dek); }); it('wrap → unwrap roundtrip with short key (hashed to 32 bytes)', async () => { const provider = new EnvKeyProvider('my-dev-secret-key'); const dek = generateAesKey(); const { wrappedKey, mekVersion } = await provider.wrapKey(dek); const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion); expect(unwrapped).toEqual(dek); }); it('throws on empty key', () => { expect(() => new EnvKeyProvider('')).toThrow(/must not be empty/); }); }); // ── DEK cache ─────────────────────────────────────── describe('DekCache', () => { let cache: DekCache; beforeEach(() => { cache = new DekCache(1000, 3); // 1s TTL, max 3 entries }); it('get returns null on miss', () => { expect(cache.get('nonexistent')).toBeNull(); }); it('set + get roundtrip', () => { const key = generateAesKey(); cache.set('dek_1', key); expect(cache.get('dek_1')).toEqual(key); }); it('expires entries after TTL', async () => { const shortCache = new DekCache(50, 100); // 50ms TTL const key = generateAesKey(); shortCache.set('dek_1', key); expect(shortCache.get('dek_1')).toEqual(key); await new Promise(r => setTimeout(r, 60)); expect(shortCache.get('dek_1')).toBeNull(); }); it('evicts oldest on max size', () => { cache.set('a', generateAesKey()); cache.set('b', generateAesKey()); cache.set('c', generateAesKey()); // At max size (3), adding 'd' should evict 'a' cache.set('d', generateAesKey()); expect(cache.get('a')).toBeNull(); expect(cache.get('d')).not.toBeNull(); }); it('invalidate removes specific entry', () => { cache.set('dek_1', generateAesKey()); cache.invalidate('dek_1'); expect(cache.get('dek_1')).toBeNull(); }); it('tracks hit rate', () => { cache.set('dek_1', generateAesKey()); cache.recordHit(); cache.recordHit(); cache.recordMiss(); expect(cache.hitRate).toBe(67); // 2/3 = 67% }); }); // ── Envelope ──────────────────────────────────────── describe('envelope', () => { it('buildDekId produces correct format', () => { expect(buildDekId('user_123', 'transcripts')).toBe('dek_user_123_transcripts'); }); it('getOrCreateDek creates and caches a new DEK', async () => { const provider = new MemoryKeyProvider(); const store = new MemoryDekStore(); const cache = new DekCache(); const dek = await getOrCreateDek('dek_u1_ctx', provider, store, cache); expect(dek).toHaveLength(32); // Should be in store const stored = await store.get('dek_u1_ctx'); expect(stored).not.toBeNull(); // Should be cached — second call should return same key const dek2 = await getOrCreateDek('dek_u1_ctx', provider, store, cache); expect(dek2).toEqual(dek); }); it('rewrapAllDeks re-wraps with new provider', async () => { const oldProvider = new MemoryKeyProvider(undefined, 'old-v1'); const newProvider = new MemoryKeyProvider(undefined, 'new-v1'); const store = new MemoryDekStore(); const cache = new DekCache(); // Create 3 DEKs with old provider await getOrCreateDek('dek_1', oldProvider, store, cache); await getOrCreateDek('dek_2', oldProvider, store, cache); await getOrCreateDek('dek_3', oldProvider, store, cache); // Re-wrap const count = await rewrapAllDeks(oldProvider, newProvider, store, cache); expect(count).toBe(3); // Verify new provider can unwrap const stored = await store.get('dek_1'); expect(stored).not.toBeNull(); expect(stored!.mekVersion).toBe('new-v1'); const unwrapped = await newProvider.unwrapKey(stored!.wrappedKey, stored!.mekVersion); expect(unwrapped).toHaveLength(32); }); }); // ── FieldEncryptor (integration) ──────────────────── describe('FieldEncryptor', () => { let encryptor: FieldEncryptor; const ctx: FieldEncryptContext = { userId: 'user_42', context: 'notes' }; beforeEach(() => { encryptor = createFieldEncryptor({ keyProvider: 'memory' }); }); it('encrypt → decrypt roundtrip', async () => { const encrypted = await encryptor.encrypt('Hello World', ctx); expect(encrypted.__encrypted).toBe(true); const decrypted = await encryptor.decrypt(encrypted, ctx); expect(decrypted).toBe('Hello World'); }); it('encryptBatch → decryptBatch roundtrip', async () => { const plaintexts = ['one', 'two', 'three']; const encrypted = await encryptor.encryptBatch(plaintexts, ctx); expect(encrypted).toHaveLength(3); const decrypted = await encryptor.decryptBatch(encrypted, ctx); expect(decrypted).toEqual(plaintexts); }); it('isEncrypted works via encryptor', async () => { const encrypted = await encryptor.encrypt('test', ctx); expect(encryptor.isEncrypted(encrypted)).toBe(true); expect(encryptor.isEncrypted('plaintext')).toBe(false); }); it('different users get different DEKs', async () => { const ctx1: FieldEncryptContext = { userId: 'user_1', context: 'notes' }; const ctx2: FieldEncryptContext = { userId: 'user_2', context: 'notes' }; const e1 = await encryptor.encrypt('same text', ctx1); const e2 = await encryptor.encrypt('same text', ctx2); expect(e1.dekId).not.toBe(e2.dekId); expect(e1.ct).not.toBe(e2.ct); }); it('JSON-serialized array encryption roundtrip', async () => { const transcript = [ { role: 'user', content: 'Hello', ts: '2026-01-01T00:00:00Z' }, { role: 'agent', content: 'Hi there!', ts: '2026-01-01T00:00:01Z' }, ]; const serialized = JSON.stringify(transcript); const encrypted = await encryptor.encrypt(serialized, ctx); const decrypted = await encryptor.decrypt(encrypted, ctx); expect(JSON.parse(decrypted)).toEqual(transcript); }); }); // ── Factory config validation ─────────────────────── describe('createFieldEncryptor config', () => { it('throws on unknown provider', () => { expect(() => createFieldEncryptor({ keyProvider: 'nope' as never })).toThrow(/unknown/); }); it('throws on env provider without key', () => { expect(() => createFieldEncryptor({ keyProvider: 'env' })).toThrow(/encryptionKey/); }); it('throws on akv provider without vaultUrl', () => { expect(() => createFieldEncryptor({ keyProvider: 'akv', mekName: 'mek' })).toThrow( /keyVaultUrl/ ); }); it('throws on akv provider without mekName', () => { expect(() => createFieldEncryptor({ keyProvider: 'akv', keyVaultUrl: 'https://kv.vault.azure.net' }) ).toThrow(/mekName/); }); it('env provider works with hex key', async () => { const hexKey = randomBytes(32).toString('hex'); const enc = createFieldEncryptor({ keyProvider: 'env', encryptionKey: hexKey }); const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' }; const encrypted = await enc.encrypt('secret', ctx); const decrypted = await enc.decrypt(encrypted, ctx); expect(decrypted).toBe('secret'); }); }); // ── Encryption toggle (enabled/disabled) ──────────── describe('enabled: false (NullFieldEncryptor)', () => { const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' }; it('createFieldEncryptor returns NullFieldEncryptor when enabled=false', () => { const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); expect(enc).toBeInstanceOf(NullFieldEncryptor); }); it('createFieldEncryptor returns real FieldEncryptor when enabled=true', () => { const enc = createFieldEncryptor({ enabled: true, keyProvider: 'memory' }); expect(enc).not.toBeInstanceOf(NullFieldEncryptor); expect(enc).toBeInstanceOf(FieldEncryptor); }); it('createFieldEncryptor returns real FieldEncryptor when enabled is omitted', () => { const enc = createFieldEncryptor({ keyProvider: 'memory' }); expect(enc).not.toBeInstanceOf(NullFieldEncryptor); }); it('encrypt returns sentinel with plaintext in ct field', async () => { const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); const result = await enc.encrypt('hello world', ctx); expect(result.__encrypted).toBe(true); expect(result.ct).toBe('hello world'); expect(result.iv).toBe('disabled'); expect(result.tag).toBe('disabled'); expect(result.dekId).toBe('disabled'); }); it('decrypt returns plaintext from ct field', async () => { const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); const encrypted = await enc.encrypt('secret', ctx); const decrypted = await enc.decrypt(encrypted, ctx); expect(decrypted).toBe('secret'); }); it('encryptBatch returns sentinels for all items', async () => { const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); const results = await enc.encryptBatch(['a', 'b', 'c'], ctx); expect(results).toHaveLength(3); expect(results[0].ct).toBe('a'); expect(results[1].ct).toBe('b'); expect(results[2].ct).toBe('c'); }); it('decryptBatch returns plaintexts from ct fields', async () => { const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); const encrypted = await enc.encryptBatch(['x', 'y'], ctx); const decrypted = await enc.decryptBatch(encrypted, ctx); expect(decrypted).toEqual(['x', 'y']); }); it('isEncrypted returns true for disabled sentinel', async () => { const enc = createFieldEncryptor({ enabled: false, keyProvider: 'memory' }); const result = await enc.encrypt('test', ctx); expect(enc.isEncrypted(result)).toBe(true); }); }); // ── Migration ─────────────────────────────────────── describe('migrateDocuments', () => { it('encrypts plaintext fields and skips already-encrypted', async () => { const encryptor = createFieldEncryptor({ keyProvider: 'memory' }); const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' }; const alreadyEncrypted = await encryptor.encrypt('old', ctx); const docs = [ { id: '1', body: 'plaintext note' }, { id: '2', body: alreadyEncrypted }, { id: '3', body: 'another note' }, { id: '4', body: null }, ]; const written: Array<{ id: string; body: EncryptedField }> = []; const result = await migrateDocuments({ fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize), getId: doc => doc.id, getField: doc => doc.body, encryptValue: plaintext => encryptor.encrypt(plaintext, ctx), writeBack: async (doc, encrypted) => { written.push({ id: doc.id, body: encrypted }); }, batchSize: 10, }); expect(result.scanned).toBe(4); expect(result.encrypted).toBe(2); // id 1 + id 3 expect(result.skipped).toBe(2); // id 2 (already encrypted) + id 4 (null) expect(result.errors).toBe(0); expect(written).toHaveLength(2); }); it('dry run does not write', async () => { const encryptor = createFieldEncryptor({ keyProvider: 'memory' }); const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' }; const docs = [{ id: '1', body: 'plaintext' }]; let writeCount = 0; const result = await migrateDocuments({ fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize), getId: doc => doc.id, getField: doc => doc.body, encryptValue: plaintext => encryptor.encrypt(plaintext, ctx), writeBack: async () => { writeCount++; }, dryRun: true, }); expect(result.encrypted).toBe(1); expect(writeCount).toBe(0); }); }); // ── CosmosDekStore ────────────────────────────────── describe('CosmosDekStore', () => { function createMockContainer() { const docs = new Map>(); const container = { item: (id: string, _pk: string) => ({ read: async () => { const resource = docs.get(id) as T | undefined; if (!resource) { const err = new Error('Not found') as Error & { code: number }; err.code = 404; throw err; } return { resource }; }, delete: async () => { if (!docs.has(id)) { const err = new Error('Not found') as Error & { code: number }; err.code = 404; throw err; } docs.delete(id); }, }), items: { upsert: async (doc: Record) => { docs.set(doc.id as string, doc); }, query: (_sql: string) => ({ fetchAll: async () => ({ resources: [...docs.values()].map(d => ({ dekId: d.dekId })), }), }), }, }; return container as unknown as import('@azure/cosmos').Container; } it('put and get a DEK', async () => { const store = new CosmosDekStore(createMockContainer()); const dek = { dekId: 'dek_test', wrappedKey: 'aabbcc', mekVersion: 'v1', createdAt: '2026-01-01T00:00:00Z', }; await store.put(dek); const got = await store.get('dek_test'); expect(got).toEqual(dek); }); it('get returns null for missing DEK', async () => { const store = new CosmosDekStore(createMockContainer()); const got = await store.get('nonexistent'); expect(got).toBeNull(); }); it('listIds returns all stored DEK IDs', async () => { const store = new CosmosDekStore(createMockContainer()); await store.put({ dekId: 'dek_a', wrappedKey: '11', mekVersion: 'v1', createdAt: '2026-01-01T00:00:00Z', }); await store.put({ dekId: 'dek_b', wrappedKey: '22', mekVersion: 'v1', createdAt: '2026-01-01T00:00:00Z', }); const ids = await store.listIds(); expect(ids).toContain('dek_a'); expect(ids).toContain('dek_b'); expect(ids.length).toBe(2); }); it('delete removes a DEK', async () => { const store = new CosmosDekStore(createMockContainer()); await store.put({ dekId: 'dek_del', wrappedKey: '33', mekVersion: 'v1', createdAt: '2026-01-01T00:00:00Z', }); await store.delete('dek_del'); expect(await store.get('dek_del')).toBeNull(); }); it('delete is idempotent (no error on missing)', async () => { const store = new CosmosDekStore(createMockContainer()); await expect(store.delete('nonexistent')).resolves.toBeUndefined(); }); it('put overwrites existing DEK (upsert)', async () => { const store = new CosmosDekStore(createMockContainer()); await store.put({ dekId: 'dek_up', wrappedKey: 'old', mekVersion: 'v1', createdAt: '2026-01-01T00:00:00Z', }); await store.put({ dekId: 'dek_up', wrappedKey: 'new', mekVersion: 'v2', createdAt: '2026-01-01T00:00:00Z', }); const got = await store.get('dek_up'); expect(got?.wrappedKey).toBe('new'); expect(got?.mekVersion).toBe('v2'); }); });