/** * @bytelyst/field-encrypt — FieldEncryptor * * Main API — wires key provider + DEK store + cache + AES-GCM. * Product backends create a singleton via createFieldEncryptor(). */ import type { EncryptedField, FieldEncryptContext, FieldEncryptorConfig, KeyProvider, DekStore, } from './types.js'; import { encryptField, decryptField } from './aes-gcm.js'; import { buildDekId, getOrCreateDek, rewrapAllDeks } from './envelope.js'; import { DekCache } from './key-cache.js'; import { MemoryDekStore } from './dek-store-memory.js'; import { MemoryKeyProvider } from './key-provider-memory.js'; import { EnvKeyProvider } from './key-provider-env.js'; import { AkvKeyProvider } from './key-provider-akv.js'; import { isEncryptedField } from './guards.js'; export class FieldEncryptor { private readonly keyProvider: KeyProvider; private readonly dekStore: DekStore; private readonly cache: DekCache; constructor(config: FieldEncryptorConfig) { this.keyProvider = resolveKeyProvider(config); this.dekStore = config.dekStore ?? new MemoryDekStore(); this.cache = new DekCache( config.dekCacheTtlMs ?? 15 * 60 * 1000, config.dekCacheMaxSize ?? 1000 ); } /** * Encrypt a plaintext string. * * Automatically gets or creates a DEK scoped to the userId + context. */ async encrypt(plaintext: string, ctx: FieldEncryptContext): Promise { const dekId = buildDekId(ctx.userId, ctx.context); const aad = `${ctx.userId}:${ctx.context}`; const dek = await getOrCreateDek(dekId, this.keyProvider, this.dekStore, this.cache); return encryptField(plaintext, dek, dekId, aad); } /** * Decrypt an EncryptedField back to plaintext. */ async decrypt(field: EncryptedField, ctx: FieldEncryptContext): Promise { const aad = `${ctx.userId}:${ctx.context}`; const dek = await getOrCreateDek(field.dekId, this.keyProvider, this.dekStore, this.cache); return decryptField(field, dek, aad); } /** * Encrypt multiple fields in a single call (optimized — single DEK lookup). */ async encryptBatch(plaintexts: string[], ctx: FieldEncryptContext): Promise { const dekId = buildDekId(ctx.userId, ctx.context); const aad = `${ctx.userId}:${ctx.context}`; const dek = await getOrCreateDek(dekId, this.keyProvider, this.dekStore, this.cache); return plaintexts.map(pt => encryptField(pt, dek, dekId, aad)); } /** * Decrypt multiple EncryptedFields in a single call. * * Groups by dekId for efficient DEK lookup. */ async decryptBatch(fields: EncryptedField[], ctx: FieldEncryptContext): Promise { const aad = `${ctx.userId}:${ctx.context}`; const dekMap = new Map(); const results: string[] = []; for (const field of fields) { let dek = dekMap.get(field.dekId); if (!dek) { dek = await getOrCreateDek(field.dekId, this.keyProvider, this.dekStore, this.cache); dekMap.set(field.dekId, dek); } results.push(decryptField(field, dek, aad)); } return results; } /** * Check if a value is an EncryptedField. */ isEncrypted(value: unknown): value is EncryptedField { return isEncryptedField(value); } /** * Re-wrap all DEKs after MEK rotation. */ async rewrapDeks( newKeyProvider: KeyProvider, onProgress?: (completed: number, total: number) => void ): Promise { return rewrapAllDeks(this.keyProvider, newKeyProvider, this.dekStore, this.cache, onProgress); } /** DEK cache hit rate (0-100). */ get cacheHitRate(): number { return this.cache.hitRate; } /** Number of cached DEKs. */ get cacheSize(): number { return this.cache.size; } /** Reset cache statistics. */ resetCacheStats(): void { this.cache.resetStats(); } /** Clear DEK cache (e.g., on shutdown). */ clearCache(): void { this.cache.clear(); } } function resolveKeyProvider(config: FieldEncryptorConfig): KeyProvider { switch (config.keyProvider) { case 'memory': return new MemoryKeyProvider(); case 'env': { const key = config.encryptionKey; if (!key) { throw new Error('FieldEncryptor: "env" key provider requires encryptionKey (hex string)'); } return new EnvKeyProvider(key); } case 'akv': { if (!config.keyVaultUrl) { throw new Error('FieldEncryptor: "akv" key provider requires keyVaultUrl'); } if (!config.mekName) { throw new Error('FieldEncryptor: "akv" key provider requires mekName'); } return new AkvKeyProvider(config.keyVaultUrl, config.mekName); } default: throw new Error(`FieldEncryptor: unknown key provider "${config.keyProvider}"`); } } /** * No-op encryptor — stores/returns plaintext unchanged. * * Returned by createFieldEncryptor({ enabled: false, ... }). * All repositories continue calling encrypt()/decrypt() without branching. */ export class NullFieldEncryptor extends FieldEncryptor { constructor() { // Use memory provider — it will never be called, but satisfies the constructor super({ keyProvider: 'memory' }); } override async encrypt(plaintext: string, _ctx: FieldEncryptContext): Promise { // Return a sentinel object that looks encrypted but stores plaintext return { __encrypted: true, v: 1, alg: 'aes-256-gcm', ct: plaintext, iv: 'disabled', tag: 'disabled', dekId: 'disabled', }; } override async decrypt(field: EncryptedField, _ctx: FieldEncryptContext): Promise { // If encryption was disabled, ct contains the plaintext directly return field.ct; } override async encryptBatch( plaintexts: string[], ctx: FieldEncryptContext ): Promise { return Promise.all(plaintexts.map(pt => this.encrypt(pt, ctx))); } override async decryptBatch( fields: EncryptedField[], ctx: FieldEncryptContext ): Promise { return Promise.all(fields.map(f => this.decrypt(f, ctx))); } } /** * Create a FieldEncryptor instance. * * Typical usage (one per backend service): * ```typescript * const encryptor = createFieldEncryptor({ * keyProvider: config.FIELD_ENCRYPT_KEY_PROVIDER ?? 'memory', * mekName: 'lysnr-mek', * keyVaultUrl: config.AZURE_KEYVAULT_URL, * }); * ``` * * To disable encryption globally or per-product: * ```typescript * const encryptor = createFieldEncryptor({ * enabled: false, // ← no-op passthrough * keyProvider: 'memory', * }); * ``` */ export function createFieldEncryptor(config: FieldEncryptorConfig): FieldEncryptor { if (config.enabled === false) { return new NullFieldEncryptor(); } return new FieldEncryptor(config); }