- 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
108 lines
2.7 KiB
TypeScript
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;
|
|
}
|