/** * @bytelyst/field-encrypt — DEK cache * * In-memory LRU cache with TTL for unwrapped DEKs. * Avoids repeated AKV round-trips on every encrypt/decrypt. */ interface CacheEntry { key: Buffer; expiresAt: number; } export class DekCache { private readonly cache = new Map(); private readonly ttlMs: number; private readonly maxSize: number; constructor(ttlMs: number = 15 * 60 * 1000, maxSize: number = 1000) { this.ttlMs = ttlMs; this.maxSize = maxSize; } /** Get an unwrapped DEK from cache. Returns null on miss or expiry. */ get(dekId: string): Buffer | null { const entry = this.cache.get(dekId); if (!entry) return null; if (Date.now() > entry.expiresAt) { this.cache.delete(dekId); return null; } // Move to end (LRU refresh) this.cache.delete(dekId); this.cache.set(dekId, entry); return entry.key; } /** Store an unwrapped DEK in cache. */ set(dekId: string, key: Buffer): void { // Evict oldest if at max size if (this.cache.size >= this.maxSize && !this.cache.has(dekId)) { const oldestKey = this.cache.keys().next().value; if (oldestKey !== undefined) { this.cache.delete(oldestKey); } } this.cache.set(dekId, { key, expiresAt: Date.now() + this.ttlMs, }); } /** Invalidate a specific DEK (e.g., after rotation). */ invalidate(dekId: string): void { this.cache.delete(dekId); } /** Clear all cached DEKs. */ clear(): void { this.cache.clear(); } /** Current cache size. */ get size(): number { return this.cache.size; } /** Cache hit rate stats. */ private _hits = 0; private _misses = 0; /** Record a cache hit (called internally). */ recordHit(): void { this._hits++; } /** Record a cache miss (called internally). */ recordMiss(): void { this._misses++; } /** Get hit rate as a percentage (0-100). */ get hitRate(): number { const total = this._hits + this._misses; return total === 0 ? 0 : Math.round((this._hits / total) * 100); } /** Reset stats counters. */ resetStats(): void { this._hits = 0; this._misses = 0; } }