learning_ai_common_plat/packages/field-encrypt/src/key-cache.ts
saravanakumardb1 bb3f5385fc feat(field-encrypt): create @bytelyst/field-encrypt package with AES-256-GCM envelope encryption
- 10 source files: types, aes-gcm, 3 key providers (memory/env/akv), envelope, key-cache, dek-store, guards, migration, factory
- 42 Vitest tests: AES-GCM roundtrips, tamper detection, unicode, 100KB payloads, key providers, DEK cache TTL/LRU, envelope lifecycle, migration (dry-run + idempotent), config validation
- AKV MEK creation script (scripts/create-encryption-keys.sh) for 10 product MEKs
- .env.example updated with FIELD_ENCRYPT_* vars
2026-03-21 09:18:10 -07:00

95 lines
2.1 KiB
TypeScript

/**
* @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<string, CacheEntry>();
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;
}
}