- FieldEncryptorConfig.enabled: false returns NullFieldEncryptor (no-op) - NullFieldEncryptor stores plaintext as-is, decrypt returns ct directly - 7 new tests for toggle behavior (50/50 total) - encryption_enabled added to COMMON_FLAGS (seeded for all 10 products)
492 lines
17 KiB
TypeScript
492 lines
17 KiB
TypeScript
/**
|
|
* @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,
|
|
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);
|
|
});
|
|
});
|