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
This commit is contained in:
saravanakumardb1 2026-03-21 09:18:10 -07:00
parent 8af997ba0f
commit bb3f5385fc
17 changed files with 1552 additions and 0 deletions

View File

@ -71,5 +71,13 @@ TELEMETRY_ALERT_WEBHOOK_URL=
TELEMETRY_GEO_API_URL=http://ip-api.com/json
TELEMETRY_EVENT_TTL_DAYS=90
# ── Field Encryption (@bytelyst/field-encrypt) ──────────────
# Key provider: 'akv' (production) | 'env' (dev/staging) | 'memory' (tests)
FIELD_ENCRYPT_KEY_PROVIDER=memory
# Hex-encoded 32-byte key — only for 'env' provider (like AUTH_TOTP_ENCRYPTION_KEY)
FIELD_ENCRYPT_KEY=
# Product-specific MEK name in AKV — only for 'akv' provider
FIELD_ENCRYPT_MEK_NAME=lysnr-mek
# ── Product Identity ──────────────────────────────────────────
DEFAULT_PRODUCT_ID=lysnrai

View File

@ -0,0 +1,40 @@
{
"name": "@bytelyst/field-encrypt",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run"
},
"dependencies": {
"@bytelyst/errors": "workspace:*"
},
"peerDependencies": {
"@azure/keyvault-keys": ">=4.8.0",
"@azure/identity": ">=4.0.0",
"zod": ">=3.22.0"
},
"peerDependenciesMeta": {
"@azure/keyvault-keys": {
"optional": true
},
"@azure/identity": {
"optional": true
}
},
"devDependencies": {
"vitest": "^3.0.0",
"zod": "^3.24.0"
}
}

View File

@ -0,0 +1,89 @@
/**
* @bytelyst/field-encrypt AES-256-GCM primitives
*
* Low-level encrypt/decrypt using Node.js native crypto.
* All higher-level APIs delegate to these functions.
*/
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import type { EncryptedField } from './types.js';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const KEY_BYTES = 32;
/**
* Encrypt a plaintext string with AES-256-GCM.
*
* @param plaintext - UTF-8 string to encrypt
* @param key - 32-byte AES key
* @param dekId - DEK identifier stored in the output
* @param aad - Optional additional authenticated data (e.g., userId + context)
* @returns EncryptedField object ready for Cosmos/SQLite storage
*/
export function encryptField(
plaintext: string,
key: Buffer,
dekId: string,
aad?: string
): EncryptedField {
if (key.length !== KEY_BYTES) {
throw new Error(`AES-256-GCM requires a ${KEY_BYTES}-byte key, got ${key.length}`);
}
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, key, iv);
if (aad) {
cipher.setAAD(Buffer.from(aad, 'utf8'));
}
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
__encrypted: true,
v: 1,
alg: 'aes-256-gcm',
ct: encrypted,
iv: iv.toString('hex'),
tag: authTag.toString('hex'),
dekId,
};
}
/**
* Decrypt an EncryptedField back to plaintext.
*
* @param field - EncryptedField object
* @param key - 32-byte AES key (must match the key used to encrypt)
* @param aad - Optional AAD (must match the AAD used during encryption)
* @returns Decrypted UTF-8 string
* @throws Error if authentication tag verification fails (tampered data)
*/
export function decryptField(field: EncryptedField, key: Buffer, aad?: string): string {
if (key.length !== KEY_BYTES) {
throw new Error(`AES-256-GCM requires a ${KEY_BYTES}-byte key, got ${key.length}`);
}
const iv = Buffer.from(field.iv, 'hex');
const authTag = Buffer.from(field.tag, 'hex');
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
if (aad) {
decipher.setAAD(Buffer.from(aad, 'utf8'));
}
let decrypted = decipher.update(field.ct, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/** Generate a random 32-byte AES-256 key. */
export function generateAesKey(): Buffer {
return randomBytes(KEY_BYTES);
}

View File

@ -0,0 +1,27 @@
/**
* @bytelyst/field-encrypt In-memory DEK store
*
* Default DEK store for dev/test. Production should use a Cosmos-backed store.
*/
import type { DekStore, WrappedDek } from './types.js';
export class MemoryDekStore implements DekStore {
private readonly deks = new Map<string, WrappedDek>();
async get(dekId: string): Promise<WrappedDek | null> {
return this.deks.get(dekId) ?? null;
}
async put(dek: WrappedDek): Promise<void> {
this.deks.set(dek.dekId, dek);
}
async listIds(): Promise<string[]> {
return [...this.deks.keys()];
}
async delete(dekId: string): Promise<void> {
this.deks.delete(dekId);
}
}

View File

@ -0,0 +1,107 @@
/**
* @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;
}

View File

@ -0,0 +1,171 @@
/**
* @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}"`);
}
}
/**
* 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,
* });
* ```
*/
export function createFieldEncryptor(config: FieldEncryptorConfig): FieldEncryptor {
return new FieldEncryptor(config);
}

View File

@ -0,0 +1,27 @@
/**
* @bytelyst/field-encrypt Type guards
*
* Utility to detect encrypted vs plaintext fields during migration.
*/
import type { EncryptedField } from './types.js';
/**
* Check if a value is an EncryptedField.
*
* Use this in repositories to handle both encrypted and plaintext fields
* during the migration period.
*/
export function isEncryptedField(value: unknown): value is EncryptedField {
return (
typeof value === 'object' &&
value !== null &&
'__encrypted' in value &&
(value as Record<string, unknown>).__encrypted === true &&
'v' in value &&
'ct' in value &&
'iv' in value &&
'tag' in value &&
'dekId' in value
);
}

View File

@ -0,0 +1,429 @@
/**
* @bytelyst/field-encrypt Tests
*
* ~35 tests covering AES-GCM, key providers, envelope, cache,
* field encryptor factory, type guards, and migration.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { randomBytes } from 'node:crypto';
import {
createFieldEncryptor,
FieldEncryptor,
isEncryptedField,
encryptField,
decryptField,
generateAesKey,
buildDekId,
getOrCreateDek,
rewrapAllDeks,
DekCache,
MemoryDekStore,
MemoryKeyProvider,
EnvKeyProvider,
migrateDocuments,
} from './index.js';
import type { EncryptedField, FieldEncryptContext } from './types.js';
// ── AES-256-GCM ─────────────────────────────────────
describe('aes-gcm', () => {
const key = generateAesKey();
const dekId = 'dek_test_ctx';
it('encrypt → decrypt roundtrip', () => {
const plaintext = 'Hello, sensitive data!';
const encrypted = encryptField(plaintext, key, dekId);
const decrypted = decryptField(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('encrypt → decrypt with AAD', () => {
const plaintext = 'With AAD';
const aad = 'user_123:transcripts';
const encrypted = encryptField(plaintext, key, dekId, aad);
const decrypted = decryptField(encrypted, key, aad);
expect(decrypted).toBe(plaintext);
});
it('rejects decryption with wrong AAD', () => {
const encrypted = encryptField('secret', key, dekId, 'correct_aad');
expect(() => decryptField(encrypted, key, 'wrong_aad')).toThrow();
});
it('rejects decryption with tampered ciphertext', () => {
const encrypted = encryptField('secret', key, dekId);
const tampered: EncryptedField = { ...encrypted, ct: 'deadbeef' };
expect(() => decryptField(tampered, key)).toThrow();
});
it('rejects decryption with tampered auth tag', () => {
const encrypted = encryptField('secret', key, dekId);
const tampered: EncryptedField = { ...encrypted, tag: '00'.repeat(16) };
expect(() => decryptField(tampered, key)).toThrow();
});
it('handles empty string', () => {
const encrypted = encryptField('', key, dekId);
const decrypted = decryptField(encrypted, key);
expect(decrypted).toBe('');
});
it('handles unicode content', () => {
const plaintext = '日本語テスト 🔐 Ñoño';
const encrypted = encryptField(plaintext, key, dekId);
const decrypted = decryptField(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('handles large payload (100 KB)', () => {
const plaintext = 'x'.repeat(100_000);
const encrypted = encryptField(plaintext, key, dekId);
const decrypted = decryptField(encrypted, key);
expect(decrypted).toBe(plaintext);
});
it('rejects wrong key size', () => {
const shortKey = randomBytes(16);
expect(() => encryptField('test', shortKey, dekId)).toThrow(/32-byte key/);
});
it('produces correct EncryptedField shape', () => {
const encrypted = encryptField('test', key, dekId);
expect(encrypted.__encrypted).toBe(true);
expect(encrypted.v).toBe(1);
expect(encrypted.alg).toBe('aes-256-gcm');
expect(encrypted.dekId).toBe(dekId);
expect(encrypted.iv).toHaveLength(24); // 12 bytes = 24 hex chars
expect(encrypted.tag).toHaveLength(32); // 16 bytes = 32 hex chars
expect(encrypted.ct.length).toBeGreaterThan(0);
});
it('generates unique IVs per encryption', () => {
const e1 = encryptField('same', key, dekId);
const e2 = encryptField('same', key, dekId);
expect(e1.iv).not.toBe(e2.iv);
expect(e1.ct).not.toBe(e2.ct);
});
});
// ── Type guard ──────────────────────────────────────
describe('isEncryptedField', () => {
it('returns true for valid EncryptedField', () => {
const field: EncryptedField = {
__encrypted: true,
v: 1,
alg: 'aes-256-gcm',
ct: 'abc',
iv: '012',
tag: '345',
dekId: 'dek_1',
};
expect(isEncryptedField(field)).toBe(true);
});
it('returns false for string', () => {
expect(isEncryptedField('hello')).toBe(false);
});
it('returns false for null', () => {
expect(isEncryptedField(null)).toBe(false);
});
it('returns false for undefined', () => {
expect(isEncryptedField(undefined)).toBe(false);
});
it('returns false for object without __encrypted', () => {
expect(isEncryptedField({ v: 1, ct: 'abc' })).toBe(false);
});
it('returns false for object with __encrypted: false', () => {
expect(
isEncryptedField({ __encrypted: false, v: 1, ct: 'a', iv: 'b', tag: 'c', dekId: 'd' })
).toBe(false);
});
});
// ── Key providers ───────────────────────────────────
describe('MemoryKeyProvider', () => {
it('wrap → unwrap roundtrip', async () => {
const provider = new MemoryKeyProvider();
const dek = generateAesKey();
const { wrappedKey, mekVersion } = await provider.wrapKey(dek);
const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion);
expect(unwrapped).toEqual(dek);
});
});
describe('EnvKeyProvider', () => {
it('wrap → unwrap roundtrip with 64-char hex key', async () => {
const hexKey = randomBytes(32).toString('hex');
const provider = new EnvKeyProvider(hexKey);
const dek = generateAesKey();
const { wrappedKey, mekVersion } = await provider.wrapKey(dek);
const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion);
expect(unwrapped).toEqual(dek);
});
it('wrap → unwrap roundtrip with short key (hashed to 32 bytes)', async () => {
const provider = new EnvKeyProvider('my-dev-secret-key');
const dek = generateAesKey();
const { wrappedKey, mekVersion } = await provider.wrapKey(dek);
const unwrapped = await provider.unwrapKey(wrappedKey, mekVersion);
expect(unwrapped).toEqual(dek);
});
it('throws on empty key', () => {
expect(() => new EnvKeyProvider('')).toThrow(/must not be empty/);
});
});
// ── DEK cache ───────────────────────────────────────
describe('DekCache', () => {
let cache: DekCache;
beforeEach(() => {
cache = new DekCache(1000, 3); // 1s TTL, max 3 entries
});
it('get returns null on miss', () => {
expect(cache.get('nonexistent')).toBeNull();
});
it('set + get roundtrip', () => {
const key = generateAesKey();
cache.set('dek_1', key);
expect(cache.get('dek_1')).toEqual(key);
});
it('expires entries after TTL', async () => {
const shortCache = new DekCache(50, 100); // 50ms TTL
const key = generateAesKey();
shortCache.set('dek_1', key);
expect(shortCache.get('dek_1')).toEqual(key);
await new Promise(r => setTimeout(r, 60));
expect(shortCache.get('dek_1')).toBeNull();
});
it('evicts oldest on max size', () => {
cache.set('a', generateAesKey());
cache.set('b', generateAesKey());
cache.set('c', generateAesKey());
// At max size (3), adding 'd' should evict 'a'
cache.set('d', generateAesKey());
expect(cache.get('a')).toBeNull();
expect(cache.get('d')).not.toBeNull();
});
it('invalidate removes specific entry', () => {
cache.set('dek_1', generateAesKey());
cache.invalidate('dek_1');
expect(cache.get('dek_1')).toBeNull();
});
it('tracks hit rate', () => {
cache.set('dek_1', generateAesKey());
cache.recordHit();
cache.recordHit();
cache.recordMiss();
expect(cache.hitRate).toBe(67); // 2/3 = 67%
});
});
// ── Envelope ────────────────────────────────────────
describe('envelope', () => {
it('buildDekId produces correct format', () => {
expect(buildDekId('user_123', 'transcripts')).toBe('dek_user_123_transcripts');
});
it('getOrCreateDek creates and caches a new DEK', async () => {
const provider = new MemoryKeyProvider();
const store = new MemoryDekStore();
const cache = new DekCache();
const dek = await getOrCreateDek('dek_u1_ctx', provider, store, cache);
expect(dek).toHaveLength(32);
// Should be in store
const stored = await store.get('dek_u1_ctx');
expect(stored).not.toBeNull();
// Should be cached — second call should return same key
const dek2 = await getOrCreateDek('dek_u1_ctx', provider, store, cache);
expect(dek2).toEqual(dek);
});
it('rewrapAllDeks re-wraps with new provider', async () => {
const oldProvider = new MemoryKeyProvider(undefined, 'old-v1');
const newProvider = new MemoryKeyProvider(undefined, 'new-v1');
const store = new MemoryDekStore();
const cache = new DekCache();
// Create 3 DEKs with old provider
await getOrCreateDek('dek_1', oldProvider, store, cache);
await getOrCreateDek('dek_2', oldProvider, store, cache);
await getOrCreateDek('dek_3', oldProvider, store, cache);
// Re-wrap
const count = await rewrapAllDeks(oldProvider, newProvider, store, cache);
expect(count).toBe(3);
// Verify new provider can unwrap
const stored = await store.get('dek_1');
expect(stored).not.toBeNull();
expect(stored!.mekVersion).toBe('new-v1');
const unwrapped = await newProvider.unwrapKey(stored!.wrappedKey, stored!.mekVersion);
expect(unwrapped).toHaveLength(32);
});
});
// ── FieldEncryptor (integration) ────────────────────
describe('FieldEncryptor', () => {
let encryptor: FieldEncryptor;
const ctx: FieldEncryptContext = { userId: 'user_42', context: 'notes' };
beforeEach(() => {
encryptor = createFieldEncryptor({ keyProvider: 'memory' });
});
it('encrypt → decrypt roundtrip', async () => {
const encrypted = await encryptor.encrypt('Hello World', ctx);
expect(encrypted.__encrypted).toBe(true);
const decrypted = await encryptor.decrypt(encrypted, ctx);
expect(decrypted).toBe('Hello World');
});
it('encryptBatch → decryptBatch roundtrip', async () => {
const plaintexts = ['one', 'two', 'three'];
const encrypted = await encryptor.encryptBatch(plaintexts, ctx);
expect(encrypted).toHaveLength(3);
const decrypted = await encryptor.decryptBatch(encrypted, ctx);
expect(decrypted).toEqual(plaintexts);
});
it('isEncrypted works via encryptor', async () => {
const encrypted = await encryptor.encrypt('test', ctx);
expect(encryptor.isEncrypted(encrypted)).toBe(true);
expect(encryptor.isEncrypted('plaintext')).toBe(false);
});
it('different users get different DEKs', async () => {
const ctx1: FieldEncryptContext = { userId: 'user_1', context: 'notes' };
const ctx2: FieldEncryptContext = { userId: 'user_2', context: 'notes' };
const e1 = await encryptor.encrypt('same text', ctx1);
const e2 = await encryptor.encrypt('same text', ctx2);
expect(e1.dekId).not.toBe(e2.dekId);
expect(e1.ct).not.toBe(e2.ct);
});
it('JSON-serialized array encryption roundtrip', async () => {
const transcript = [
{ role: 'user', content: 'Hello', ts: '2026-01-01T00:00:00Z' },
{ role: 'agent', content: 'Hi there!', ts: '2026-01-01T00:00:01Z' },
];
const serialized = JSON.stringify(transcript);
const encrypted = await encryptor.encrypt(serialized, ctx);
const decrypted = await encryptor.decrypt(encrypted, ctx);
expect(JSON.parse(decrypted)).toEqual(transcript);
});
});
// ── Factory config validation ───────────────────────
describe('createFieldEncryptor config', () => {
it('throws on unknown provider', () => {
expect(() => createFieldEncryptor({ keyProvider: 'nope' as never })).toThrow(/unknown/);
});
it('throws on env provider without key', () => {
expect(() => createFieldEncryptor({ keyProvider: 'env' })).toThrow(/encryptionKey/);
});
it('throws on akv provider without vaultUrl', () => {
expect(() => createFieldEncryptor({ keyProvider: 'akv', mekName: 'mek' })).toThrow(
/keyVaultUrl/
);
});
it('throws on akv provider without mekName', () => {
expect(() =>
createFieldEncryptor({ keyProvider: 'akv', keyVaultUrl: 'https://kv.vault.azure.net' })
).toThrow(/mekName/);
});
it('env provider works with hex key', async () => {
const hexKey = randomBytes(32).toString('hex');
const enc = createFieldEncryptor({ keyProvider: 'env', encryptionKey: hexKey });
const ctx: FieldEncryptContext = { userId: 'u1', context: 'test' };
const encrypted = await enc.encrypt('secret', ctx);
const decrypted = await enc.decrypt(encrypted, ctx);
expect(decrypted).toBe('secret');
});
});
// ── Migration ───────────────────────────────────────
describe('migrateDocuments', () => {
it('encrypts plaintext fields and skips already-encrypted', async () => {
const encryptor = createFieldEncryptor({ keyProvider: 'memory' });
const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' };
const alreadyEncrypted = await encryptor.encrypt('old', ctx);
const docs = [
{ id: '1', body: 'plaintext note' },
{ id: '2', body: alreadyEncrypted },
{ id: '3', body: 'another note' },
{ id: '4', body: null },
];
const written: Array<{ id: string; body: EncryptedField }> = [];
const result = await migrateDocuments({
fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize),
getId: doc => doc.id,
getField: doc => doc.body,
encryptValue: plaintext => encryptor.encrypt(plaintext, ctx),
writeBack: async (doc, encrypted) => {
written.push({ id: doc.id, body: encrypted });
},
batchSize: 10,
});
expect(result.scanned).toBe(4);
expect(result.encrypted).toBe(2); // id 1 + id 3
expect(result.skipped).toBe(2); // id 2 (already encrypted) + id 4 (null)
expect(result.errors).toBe(0);
expect(written).toHaveLength(2);
});
it('dry run does not write', async () => {
const encryptor = createFieldEncryptor({ keyProvider: 'memory' });
const ctx: FieldEncryptContext = { userId: 'u1', context: 'notes' };
const docs = [{ id: '1', body: 'plaintext' }];
let writeCount = 0;
const result = await migrateDocuments({
fetchBatch: async (offset, batchSize) => docs.slice(offset, offset + batchSize),
getId: doc => doc.id,
getField: doc => doc.body,
encryptValue: plaintext => encryptor.encrypt(plaintext, ctx),
writeBack: async () => {
writeCount++;
},
dryRun: true,
});
expect(result.encrypted).toBe(1);
expect(writeCount).toBe(0);
});
});

View File

@ -0,0 +1,57 @@
/**
* @bytelyst/field-encrypt
*
* Application-layer field encryption for ByteLyst ecosystem.
* AES-256-GCM with envelope encryption (MEK DEK).
*
* @example
* ```typescript
* import { createFieldEncryptor } from '@bytelyst/field-encrypt';
*
* const encryptor = createFieldEncryptor({
* keyProvider: 'memory', // 'akv' | 'env' | 'memory'
* });
*
* const encrypted = await encryptor.encrypt('sensitive data', {
* userId: 'user_123',
* context: 'transcripts',
* });
*
* const plaintext = await encryptor.decrypt(encrypted, {
* userId: 'user_123',
* context: 'transcripts',
* });
* ```
*/
// ── Main API ────────────────────────────────────────
export { createFieldEncryptor, FieldEncryptor } from './field-encryptor.js';
// ── Type guards ─────────────────────────────────────
export { isEncryptedField } from './guards.js';
// ── Types ───────────────────────────────────────────
export type {
EncryptedField,
WrappedDek,
FieldEncryptContext,
FieldEncryptorConfig,
KeyProvider,
KeyProviderType,
DekStore,
} from './types.js';
// ── Low-level (for custom integrations) ─────────────
export { encryptField, decryptField, generateAesKey } from './aes-gcm.js';
export { buildDekId, getOrCreateDek, rewrapAllDeks } from './envelope.js';
export { DekCache } from './key-cache.js';
export { MemoryDekStore } from './dek-store-memory.js';
// ── Key providers (for direct use / testing) ────────
export { MemoryKeyProvider } from './key-provider-memory.js';
export { EnvKeyProvider } from './key-provider-env.js';
export { AkvKeyProvider } from './key-provider-akv.js';
// ── Migration ───────────────────────────────────────
export { migrateDocuments } from './migration.js';
export type { MigrationResult, MigrateDocumentsOptions } from './migration.js';

View File

@ -0,0 +1,94 @@
/**
* @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;
}
}

View File

@ -0,0 +1,68 @@
/**
* @bytelyst/field-encrypt Azure Key Vault key provider
*
* Production provider uses AKV RSA keys for DEK wrapping.
* Requires @azure/keyvault-keys and @azure/identity as peer deps.
*/
import type { KeyProvider } from './types.js';
/**
* Azure Key Vault key provider.
*
* Uses RSA-OAEP wrapping the MEK never leaves AKV.
* Requires:
* - @azure/keyvault-keys (CryptographyClient)
* - @azure/identity (DefaultAzureCredential)
* - AKV RBAC: Key Vault Crypto User role on the managed identity
*/
export class AkvKeyProvider implements KeyProvider {
private readonly vaultUrl: string;
private readonly mekName: string;
private cryptoClient: unknown | null = null;
constructor(vaultUrl: string, mekName: string) {
if (!vaultUrl) throw new Error('AkvKeyProvider: vaultUrl is required');
if (!mekName) throw new Error('AkvKeyProvider: mekName is required');
this.vaultUrl = vaultUrl;
this.mekName = mekName;
}
private async getClient(): Promise<{
wrapKey(alg: string, key: Uint8Array): Promise<{ result: Uint8Array }>;
unwrapKey(alg: string, key: Uint8Array): Promise<{ result: Uint8Array }>;
}> {
if (this.cryptoClient) return this.cryptoClient as never;
// Dynamic import to keep peer deps optional
const { KeyClient, CryptographyClient } = await import('@azure/keyvault-keys');
const { DefaultAzureCredential } = await import('@azure/identity');
const credential = new DefaultAzureCredential();
const keyClient = new KeyClient(this.vaultUrl, credential);
const key = await keyClient.getKey(this.mekName);
if (!key.id) {
throw new Error(`AkvKeyProvider: MEK '${this.mekName}' not found in ${this.vaultUrl}`);
}
this.cryptoClient = new CryptographyClient(key.id, credential);
return this.cryptoClient as never;
}
async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> {
const client = await this.getClient();
const result = await client.wrapKey('RSA-OAEP-256', new Uint8Array(dek));
return {
wrappedKey: Buffer.from(result.result).toString('hex'),
mekVersion: this.mekName,
};
}
async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise<Buffer> {
const client = await this.getClient();
const wrappedBytes = new Uint8Array(Buffer.from(wrappedKeyHex, 'hex'));
const result = await client.unwrapKey('RSA-OAEP-256', wrappedBytes);
return Buffer.from(result.result);
}
}

View File

@ -0,0 +1,62 @@
/**
* @bytelyst/field-encrypt Environment variable key provider
*
* For dev/staging uses a hex-encoded symmetric key from an env var.
* Matches the existing MFA pattern (AUTH_TOTP_ENCRYPTION_KEY).
*
* Wrapping uses AES-256-GCM with the env key as MEK.
*/
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import type { KeyProvider } from './types.js';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
export class EnvKeyProvider implements KeyProvider {
private readonly mek: Buffer;
private readonly version: string;
/**
* @param keyHex - Hex-encoded 32-byte key (64 hex chars).
* If shorter, it will be SHA-256 hashed to derive a 32-byte key.
*/
constructor(keyHex: string) {
if (!keyHex || keyHex.length === 0) {
throw new Error('EnvKeyProvider: encryption key must not be empty');
}
if (keyHex.length === 64) {
this.mek = Buffer.from(keyHex, 'hex');
} else {
// Hash to 32 bytes — same approach as existing MFA encryption
this.mek = createHash('sha256').update(keyHex).digest();
}
this.version = 'env-v1';
}
async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, this.mek, iv);
let encrypted = cipher.update(dek);
encrypted = Buffer.concat([encrypted, cipher.final()]);
const tag = cipher.getAuthTag();
const wrapped = Buffer.concat([iv, tag, encrypted]);
return { wrappedKey: wrapped.toString('hex'), mekVersion: this.version };
}
async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise<Buffer> {
const wrapped = Buffer.from(wrappedKeyHex, 'hex');
const iv = wrapped.subarray(0, IV_BYTES);
const tag = wrapped.subarray(IV_BYTES, IV_BYTES + 16);
const ciphertext = wrapped.subarray(IV_BYTES + 16);
const decipher = createDecipheriv(ALGORITHM, this.mek, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(ciphertext);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted;
}
}

View File

@ -0,0 +1,48 @@
/**
* @bytelyst/field-encrypt In-memory key provider
*
* For unit tests no external dependencies.
* Generates a random MEK on instantiation. Wrapping is just XOR for simplicity in tests,
* but uses AES-256-GCM to match production semantics.
*/
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import type { KeyProvider } from './types.js';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
export class MemoryKeyProvider implements KeyProvider {
private readonly mek: Buffer;
private readonly version: string;
constructor(mek?: Buffer, version?: string) {
this.mek = mek ?? randomBytes(32);
this.version = version ?? 'memory-v1';
}
async wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }> {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, this.mek, iv);
let encrypted = cipher.update(dek);
encrypted = Buffer.concat([encrypted, cipher.final()]);
const tag = cipher.getAuthTag();
// Format: iv (12) + tag (16) + ciphertext
const wrapped = Buffer.concat([iv, tag, encrypted]);
return { wrappedKey: wrapped.toString('hex'), mekVersion: this.version };
}
async unwrapKey(wrappedKeyHex: string, _mekVersion: string): Promise<Buffer> {
const wrapped = Buffer.from(wrappedKeyHex, 'hex');
const iv = wrapped.subarray(0, IV_BYTES);
const tag = wrapped.subarray(IV_BYTES, IV_BYTES + 16);
const ciphertext = wrapped.subarray(IV_BYTES + 16);
const decipher = createDecipheriv(ALGORITHM, this.mek, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(ciphertext);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted;
}
}

View File

@ -0,0 +1,110 @@
/**
* @bytelyst/field-encrypt Migration helpers
*
* Utilities for encrypting existing plaintext fields in-place.
* Idempotent skips already-encrypted fields via __encrypted sentinel.
*/
import type { EncryptedField } from './types.js';
import { isEncryptedField } from './guards.js';
/** Result of a migration run. */
export interface MigrationResult {
/** Total documents scanned. */
scanned: number;
/** Documents encrypted in this run. */
encrypted: number;
/** Documents skipped (already encrypted). */
skipped: number;
/** Documents that failed to encrypt. */
errors: number;
/** Error details (first 10). */
errorDetails: Array<{ id: string; error: string }>;
}
/** Options for migrateDocuments(). */
export interface MigrateDocumentsOptions<T> {
/** Fetch a batch of documents. Return empty array when done. */
fetchBatch: (offset: number, batchSize: number) => Promise<T[]>;
/** Get the document ID for logging. */
getId: (doc: T) => string;
/** Get the field value to check/encrypt. */
getField: (doc: T) => unknown;
/** Encrypt the plaintext value. Returns the EncryptedField. */
encryptValue: (plaintext: string, doc: T) => Promise<EncryptedField>;
/** Write the encrypted value back to the store. */
writeBack: (doc: T, encrypted: EncryptedField) => Promise<void>;
/** Batch size (default: 100). */
batchSize?: number;
/** If true, don't write — just count. */
dryRun?: boolean;
/** Progress callback. */
onProgress?: (result: MigrationResult) => void;
}
/**
* Migrate plaintext fields to encrypted fields in batches.
*
* Idempotent: skips documents where the field is already an EncryptedField.
*/
export async function migrateDocuments<T>(
options: MigrateDocumentsOptions<T>
): Promise<MigrationResult> {
const batchSize = options.batchSize ?? 100;
const result: MigrationResult = {
scanned: 0,
encrypted: 0,
skipped: 0,
errors: 0,
errorDetails: [],
};
let offset = 0;
let batch: T[];
do {
batch = await options.fetchBatch(offset, batchSize);
for (const doc of batch) {
result.scanned++;
const fieldValue = options.getField(doc);
// Skip already-encrypted
if (isEncryptedField(fieldValue)) {
result.skipped++;
continue;
}
// Skip null/undefined
if (fieldValue == null) {
result.skipped++;
continue;
}
const plaintext = typeof fieldValue === 'string' ? fieldValue : JSON.stringify(fieldValue);
try {
const encrypted = await options.encryptValue(plaintext, doc);
if (!options.dryRun) {
await options.writeBack(doc, encrypted);
}
result.encrypted++;
} catch (err) {
result.errors++;
if (result.errorDetails.length < 10) {
result.errorDetails.push({
id: options.getId(doc),
error: err instanceof Error ? err.message : String(err),
});
}
}
}
offset += batch.length;
options.onProgress?.(result);
} while (batch.length === batchSize);
return result;
}

View File

@ -0,0 +1,84 @@
/**
* @bytelyst/field-encrypt Types
*
* Core type definitions for field-level encryption.
*/
/** Encrypted field stored in Cosmos DB or SQLite. */
export interface EncryptedField {
/** Sentinel — always true for encrypted fields. */
readonly __encrypted: true;
/** Schema version for future algorithm changes. */
readonly v: 1;
/** Algorithm identifier. */
readonly alg: 'aes-256-gcm';
/** Ciphertext (hex-encoded). */
readonly ct: string;
/** Initialization vector (hex-encoded, 12 bytes / 24 hex chars). */
readonly iv: string;
/** GCM authentication tag (hex-encoded, 16 bytes / 32 hex chars). */
readonly tag: string;
/** DEK identifier — identifies which key to unwrap for decryption. */
readonly dekId: string;
}
/** Wrapped DEK stored alongside data (e.g., in a `_encryption_keys` Cosmos container). */
export interface WrappedDek {
/** Unique DEK identifier, e.g. `dek_user123_transcripts`. */
readonly dekId: string;
/** Wrapped (encrypted) DEK bytes (hex-encoded). */
readonly wrappedKey: string;
/** MEK name/version used to wrap this DEK. */
readonly mekVersion: string;
/** ISO 8601 creation timestamp. */
readonly createdAt: string;
}
/** Options for encrypt/decrypt operations. */
export interface FieldEncryptContext {
/** Scope for DEK isolation (typically userId). */
readonly userId: string;
/** Additional context for DEK naming and AAD (e.g., 'transcripts', 'notes'). */
readonly context: string;
}
/** Key provider — abstraction over key storage backends. */
export interface KeyProvider {
/** Wrap (encrypt) a DEK with the master key. Returns hex-encoded wrapped key + mek version string. */
wrapKey(dek: Buffer): Promise<{ wrappedKey: string; mekVersion: string }>;
/** Unwrap (decrypt) a wrapped DEK. Returns raw DEK buffer. */
unwrapKey(wrappedKeyHex: string, mekVersion: string): Promise<Buffer>;
}
/** Supported key provider types. */
export type KeyProviderType = 'akv' | 'env' | 'memory';
/** DEK store — abstraction over DEK persistence. */
export interface DekStore {
/** Get a wrapped DEK by its ID. Returns null if not found. */
get(dekId: string): Promise<WrappedDek | null>;
/** Store a wrapped DEK. */
put(dek: WrappedDek): Promise<void>;
/** List all DEK IDs (for rotation). */
listIds(): Promise<string[]>;
/** Delete a DEK. */
delete(dekId: string): Promise<void>;
}
/** Configuration for createFieldEncryptor(). */
export interface FieldEncryptorConfig {
/** Key provider type. */
keyProvider: KeyProviderType;
/** Azure Key Vault URL (required for 'akv' provider). */
keyVaultUrl?: string;
/** MEK name in AKV (required for 'akv' provider). */
mekName?: string;
/** Hex-encoded encryption key (required for 'env' provider). */
encryptionKey?: string;
/** DEK cache TTL in milliseconds (default: 15 minutes). */
dekCacheTtlMs?: number;
/** DEK cache max size (default: 1000). */
dekCacheMaxSize?: number;
/** DEK store implementation (default: in-memory). */
dekStore?: DekStore;
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

122
scripts/create-encryption-keys.sh Executable file
View File

@ -0,0 +1,122 @@
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────────────────────
# create-encryption-keys.sh — Create MEKs for all ByteLyst products in AKV
#
# Prerequisites:
# 1. Azure CLI logged in (az login)
# 2. Key Vault RBAC mode enabled on kv-mywisprai
# 3. Caller has "Key Vault Crypto Officer" role
#
# Usage:
# ./scripts/create-encryption-keys.sh [--vault-name kv-mywisprai] [--dry-run]
# ──────────────────────────────────────────────────────────────────────────────
set -euo pipefail
VAULT_NAME="${VAULT_NAME:-kv-mywisprai}"
DRY_RUN=false
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--vault-name) VAULT_NAME="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
# Product MEK names (one per product)
MEKS=(
"lysnr-mek"
"mindlyst-mek"
"jarvisjr-mek"
"chronomind-mek"
"nomgap-mek"
"peakpulse-mek"
"flowmonk-mek"
"actiontrail-mek"
"notelett-mek"
"localmemgpt-mek"
)
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ByteLyst — Create Master Encryption Keys (MEKs) in AKV ║"
echo "╠═══════════════════════════════════════════════════════════════╣"
echo "║ Vault: $VAULT_NAME"
echo "║ Key type: RSA 4096-bit"
echo "║ Ops: wrapKey, unwrapKey"
echo "║ Keys: ${#MEKS[@]}"
echo "║ Dry run: $DRY_RUN"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo ""
# Step 1: Verify AKV RBAC mode
echo "── Step 1: Verify AKV RBAC mode ─────────────────────────────"
RBAC_ENABLED=$(az keyvault show --name "$VAULT_NAME" --query "properties.enableRbacAuthorization" -o tsv 2>/dev/null || echo "unknown")
if [[ "$RBAC_ENABLED" != "true" ]]; then
echo "⚠️ WARNING: RBAC is not enabled on $VAULT_NAME (current: $RBAC_ENABLED)"
echo " Enable with: az keyvault update --name $VAULT_NAME --enable-rbac-authorization true"
echo " ⚠️ This is a BREAKING CHANGE — existing access policies will stop working."
echo " Proceed anyway? (y/N)"
read -r CONFIRM
if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
echo "Aborted."
exit 1
fi
else
echo "✅ RBAC mode is enabled on $VAULT_NAME"
fi
echo ""
# Step 2: Create MEKs
echo "── Step 2: Create MEKs ──────────────────────────────────────"
CREATED=0
SKIPPED=0
ERRORS=0
for MEK in "${MEKS[@]}"; do
# Check if key already exists
EXISTS=$(az keyvault key show --vault-name "$VAULT_NAME" --name "$MEK" --query "key.kid" -o tsv 2>/dev/null || echo "")
if [[ -n "$EXISTS" ]]; then
echo " ⏭️ $MEK — already exists ($EXISTS)"
SKIPPED=$((SKIPPED + 1))
continue
fi
if $DRY_RUN; then
echo " 🔍 $MEK — would create (dry run)"
CREATED=$((CREATED + 1))
continue
fi
echo -n " 🔑 $MEK — creating... "
if az keyvault key create \
--vault-name "$VAULT_NAME" \
--name "$MEK" \
--kty RSA \
--size 4096 \
--ops wrapKey unwrapKey \
--protection software \
-o none 2>/dev/null; then
echo "✅"
CREATED=$((CREATED + 1))
else
echo "❌ FAILED"
ERRORS=$((ERRORS + 1))
fi
done
echo ""
echo "── Summary ──────────────────────────────────────────────────"
echo " Created: $CREATED"
echo " Skipped: $SKIPPED (already exist)"
echo " Errors: $ERRORS"
echo ""
if [[ $ERRORS -gt 0 ]]; then
echo "⚠️ Some keys failed to create. Check AKV access and retry."
exit 1
fi
echo "✅ Done. MEKs ready for @bytelyst/field-encrypt envelope encryption."