learning_ai_common_plat/packages/field-encrypt/src/field-encryptor.ts
saravanakumardb1 7613d6890f feat(field-encrypt): admin-panel encryption toggle via feature flags
- FieldEncryptorConfig.enabled: false returns NullFieldEncryptor (no-op)
- NullFieldEncryptor stores plaintext as-is, decrypt returns ct directly
- 7 new tests for toggle behavior (50/50 total)
- encryption_enabled added to COMMON_FLAGS (seeded for all 10 products)
2026-03-21 15:24:19 -07:00

228 lines
6.7 KiB
TypeScript

/**
* @bytelyst/field-encrypt — FieldEncryptor
*
* Main API — wires key provider + DEK store + cache + AES-GCM.
* Product backends create a singleton via createFieldEncryptor().
*/
import type {
EncryptedField,
FieldEncryptContext,
FieldEncryptorConfig,
KeyProvider,
DekStore,
} from './types.js';
import { encryptField, decryptField } from './aes-gcm.js';
import { buildDekId, getOrCreateDek, rewrapAllDeks } from './envelope.js';
import { DekCache } from './key-cache.js';
import { MemoryDekStore } from './dek-store-memory.js';
import { MemoryKeyProvider } from './key-provider-memory.js';
import { EnvKeyProvider } from './key-provider-env.js';
import { AkvKeyProvider } from './key-provider-akv.js';
import { isEncryptedField } from './guards.js';
export class FieldEncryptor {
private readonly keyProvider: KeyProvider;
private readonly dekStore: DekStore;
private readonly cache: DekCache;
constructor(config: FieldEncryptorConfig) {
this.keyProvider = resolveKeyProvider(config);
this.dekStore = config.dekStore ?? new MemoryDekStore();
this.cache = new DekCache(
config.dekCacheTtlMs ?? 15 * 60 * 1000,
config.dekCacheMaxSize ?? 1000
);
}
/**
* Encrypt a plaintext string.
*
* Automatically gets or creates a DEK scoped to the userId + context.
*/
async encrypt(plaintext: string, ctx: FieldEncryptContext): Promise<EncryptedField> {
const dekId = buildDekId(ctx.userId, ctx.context);
const aad = `${ctx.userId}:${ctx.context}`;
const dek = await getOrCreateDek(dekId, this.keyProvider, this.dekStore, this.cache);
return encryptField(plaintext, dek, dekId, aad);
}
/**
* Decrypt an EncryptedField back to plaintext.
*/
async decrypt(field: EncryptedField, ctx: FieldEncryptContext): Promise<string> {
const aad = `${ctx.userId}:${ctx.context}`;
const dek = await getOrCreateDek(field.dekId, this.keyProvider, this.dekStore, this.cache);
return decryptField(field, dek, aad);
}
/**
* Encrypt multiple fields in a single call (optimized — single DEK lookup).
*/
async encryptBatch(plaintexts: string[], ctx: FieldEncryptContext): Promise<EncryptedField[]> {
const dekId = buildDekId(ctx.userId, ctx.context);
const aad = `${ctx.userId}:${ctx.context}`;
const dek = await getOrCreateDek(dekId, this.keyProvider, this.dekStore, this.cache);
return plaintexts.map(pt => encryptField(pt, dek, dekId, aad));
}
/**
* Decrypt multiple EncryptedFields in a single call.
*
* Groups by dekId for efficient DEK lookup.
*/
async decryptBatch(fields: EncryptedField[], ctx: FieldEncryptContext): Promise<string[]> {
const aad = `${ctx.userId}:${ctx.context}`;
const dekMap = new Map<string, Buffer>();
const results: string[] = [];
for (const field of fields) {
let dek = dekMap.get(field.dekId);
if (!dek) {
dek = await getOrCreateDek(field.dekId, this.keyProvider, this.dekStore, this.cache);
dekMap.set(field.dekId, dek);
}
results.push(decryptField(field, dek, aad));
}
return results;
}
/**
* Check if a value is an EncryptedField.
*/
isEncrypted(value: unknown): value is EncryptedField {
return isEncryptedField(value);
}
/**
* Re-wrap all DEKs after MEK rotation.
*/
async rewrapDeks(
newKeyProvider: KeyProvider,
onProgress?: (completed: number, total: number) => void
): Promise<number> {
return rewrapAllDeks(this.keyProvider, newKeyProvider, this.dekStore, this.cache, onProgress);
}
/** DEK cache hit rate (0-100). */
get cacheHitRate(): number {
return this.cache.hitRate;
}
/** Number of cached DEKs. */
get cacheSize(): number {
return this.cache.size;
}
/** Reset cache statistics. */
resetCacheStats(): void {
this.cache.resetStats();
}
/** Clear DEK cache (e.g., on shutdown). */
clearCache(): void {
this.cache.clear();
}
}
function resolveKeyProvider(config: FieldEncryptorConfig): KeyProvider {
switch (config.keyProvider) {
case 'memory':
return new MemoryKeyProvider();
case 'env': {
const key = config.encryptionKey;
if (!key) {
throw new Error('FieldEncryptor: "env" key provider requires encryptionKey (hex string)');
}
return new EnvKeyProvider(key);
}
case 'akv': {
if (!config.keyVaultUrl) {
throw new Error('FieldEncryptor: "akv" key provider requires keyVaultUrl');
}
if (!config.mekName) {
throw new Error('FieldEncryptor: "akv" key provider requires mekName');
}
return new AkvKeyProvider(config.keyVaultUrl, config.mekName);
}
default:
throw new Error(`FieldEncryptor: unknown key provider "${config.keyProvider}"`);
}
}
/**
* No-op encryptor — stores/returns plaintext unchanged.
*
* Returned by createFieldEncryptor({ enabled: false, ... }).
* All repositories continue calling encrypt()/decrypt() without branching.
*/
export class NullFieldEncryptor extends FieldEncryptor {
constructor() {
// Use memory provider — it will never be called, but satisfies the constructor
super({ keyProvider: 'memory' });
}
override async encrypt(plaintext: string, _ctx: FieldEncryptContext): Promise<EncryptedField> {
// Return a sentinel object that looks encrypted but stores plaintext
return {
__encrypted: true,
v: 1,
alg: 'aes-256-gcm',
ct: plaintext,
iv: 'disabled',
tag: 'disabled',
dekId: 'disabled',
};
}
override async decrypt(field: EncryptedField, _ctx: FieldEncryptContext): Promise<string> {
// If encryption was disabled, ct contains the plaintext directly
return field.ct;
}
override async encryptBatch(
plaintexts: string[],
ctx: FieldEncryptContext
): Promise<EncryptedField[]> {
return Promise.all(plaintexts.map(pt => this.encrypt(pt, ctx)));
}
override async decryptBatch(
fields: EncryptedField[],
ctx: FieldEncryptContext
): Promise<string[]> {
return Promise.all(fields.map(f => this.decrypt(f, ctx)));
}
}
/**
* Create a FieldEncryptor instance.
*
* Typical usage (one per backend service):
* ```typescript
* const encryptor = createFieldEncryptor({
* keyProvider: config.FIELD_ENCRYPT_KEY_PROVIDER ?? 'memory',
* mekName: 'lysnr-mek',
* keyVaultUrl: config.AZURE_KEYVAULT_URL,
* });
* ```
*
* To disable encryption globally or per-product:
* ```typescript
* const encryptor = createFieldEncryptor({
* enabled: false, // ← no-op passthrough
* keyProvider: 'memory',
* });
* ```
*/
export function createFieldEncryptor(config: FieldEncryptorConfig): FieldEncryptor {
if (config.enabled === false) {
return new NullFieldEncryptor();
}
return new FieldEncryptor(config);
}