- 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
95 lines
2.1 KiB
TypeScript
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;
|
|
}
|
|
}
|