learning_ai_common_plat/packages/field-encrypt/src/envelope.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

108 lines
2.7 KiB
TypeScript

/**
* @bytelyst/field-encrypt — Envelope encryption
*
* DEK lifecycle: generate → wrap with MEK → store.
* On use: load wrapped DEK → unwrap with MEK → cache → use for AES-GCM.
*/
import type { KeyProvider, DekStore, WrappedDek } from './types.js';
import { generateAesKey } from './aes-gcm.js';
import { DekCache } from './key-cache.js';
/**
* Build a deterministic DEK ID from userId + context.
* Format: `dek_{userId}_{context}`
*/
export function buildDekId(userId: string, context: string): string {
return `dek_${userId}_${context}`;
}
/**
* Get or create a DEK for the given scope.
*
* 1. Check cache → return if found
* 2. Check DEK store → unwrap + cache if found
* 3. Generate new DEK → wrap → store → cache → return
*/
export async function getOrCreateDek(
dekId: string,
keyProvider: KeyProvider,
dekStore: DekStore,
cache: DekCache
): Promise<Buffer> {
// 1. Cache hit
const cached = cache.get(dekId);
if (cached) {
cache.recordHit();
return cached;
}
cache.recordMiss();
// 2. DEK store hit — unwrap + cache
const stored = await dekStore.get(dekId);
if (stored) {
const dek = await keyProvider.unwrapKey(stored.wrappedKey, stored.mekVersion);
cache.set(dekId, dek);
return dek;
}
// 3. Generate new DEK → wrap → store → cache
const dek = generateAesKey();
const { wrappedKey, mekVersion } = await keyProvider.wrapKey(dek);
const wrappedDek: WrappedDek = {
dekId,
wrappedKey,
mekVersion,
createdAt: new Date().toISOString(),
};
await dekStore.put(wrappedDek);
cache.set(dekId, dek);
return dek;
}
/**
* Re-wrap all DEKs after MEK rotation.
*
* Reads each wrapped DEK, unwraps with old MEK, wraps with new MEK, stores updated.
*/
export async function rewrapAllDeks(
oldKeyProvider: KeyProvider,
newKeyProvider: KeyProvider,
dekStore: DekStore,
cache: DekCache,
onProgress?: (completed: number, total: number) => void
): Promise<number> {
const dekIds = await dekStore.listIds();
let completed = 0;
for (const dekId of dekIds) {
const stored = await dekStore.get(dekId);
if (!stored) continue;
// Unwrap with old MEK
const rawDek = await oldKeyProvider.unwrapKey(stored.wrappedKey, stored.mekVersion);
// Wrap with new MEK
const { wrappedKey, mekVersion } = await newKeyProvider.wrapKey(rawDek);
// Store updated wrapped DEK
const updated: WrappedDek = {
dekId,
wrappedKey,
mekVersion,
createdAt: stored.createdAt,
};
await dekStore.put(updated);
// Invalidate cache entry so it gets re-unwrapped with new MEK next time
cache.invalidate(dekId);
completed++;
onProgress?.(completed, dekIds.length);
}
return completed;
}