- 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)
228 lines
6.7 KiB
TypeScript
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);
|
|
}
|