diff --git a/.env.example b/.env.example index 0259bbd7..29ac0184 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/packages/field-encrypt/package.json b/packages/field-encrypt/package.json new file mode 100644 index 00000000..92fed44c --- /dev/null +++ b/packages/field-encrypt/package.json @@ -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" + } +} diff --git a/packages/field-encrypt/src/aes-gcm.ts b/packages/field-encrypt/src/aes-gcm.ts new file mode 100644 index 00000000..4eedc37a --- /dev/null +++ b/packages/field-encrypt/src/aes-gcm.ts @@ -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); +} diff --git a/packages/field-encrypt/src/dek-store-memory.ts b/packages/field-encrypt/src/dek-store-memory.ts new file mode 100644 index 00000000..79aabc36 --- /dev/null +++ b/packages/field-encrypt/src/dek-store-memory.ts @@ -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(); + + async get(dekId: string): Promise { + return this.deks.get(dekId) ?? null; + } + + async put(dek: WrappedDek): Promise { + this.deks.set(dek.dekId, dek); + } + + async listIds(): Promise { + return [...this.deks.keys()]; + } + + async delete(dekId: string): Promise { + this.deks.delete(dekId); + } +} diff --git a/packages/field-encrypt/src/envelope.ts b/packages/field-encrypt/src/envelope.ts new file mode 100644 index 00000000..a062db30 --- /dev/null +++ b/packages/field-encrypt/src/envelope.ts @@ -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 { + // 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 { + 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; +} diff --git a/packages/field-encrypt/src/field-encryptor.ts b/packages/field-encrypt/src/field-encryptor.ts new file mode 100644 index 00000000..7ee91102 --- /dev/null +++ b/packages/field-encrypt/src/field-encryptor.ts @@ -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 { + 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 { + 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 { + 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 { + const aad = `${ctx.userId}:${ctx.context}`; + const dekMap = new Map(); + + 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 { + 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); +} diff --git a/packages/field-encrypt/src/guards.ts b/packages/field-encrypt/src/guards.ts new file mode 100644 index 00000000..f8c63b8d --- /dev/null +++ b/packages/field-encrypt/src/guards.ts @@ -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).__encrypted === true && + 'v' in value && + 'ct' in value && + 'iv' in value && + 'tag' in value && + 'dekId' in value + ); +} diff --git a/packages/field-encrypt/src/index.test.ts b/packages/field-encrypt/src/index.test.ts new file mode 100644 index 00000000..0e3b4408 --- /dev/null +++ b/packages/field-encrypt/src/index.test.ts @@ -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); + }); +}); diff --git a/packages/field-encrypt/src/index.ts b/packages/field-encrypt/src/index.ts new file mode 100644 index 00000000..5c5d22c0 --- /dev/null +++ b/packages/field-encrypt/src/index.ts @@ -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'; diff --git a/packages/field-encrypt/src/key-cache.ts b/packages/field-encrypt/src/key-cache.ts new file mode 100644 index 00000000..86682525 --- /dev/null +++ b/packages/field-encrypt/src/key-cache.ts @@ -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(); + 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; + } +} diff --git a/packages/field-encrypt/src/key-provider-akv.ts b/packages/field-encrypt/src/key-provider-akv.ts new file mode 100644 index 00000000..e2a9ff5c --- /dev/null +++ b/packages/field-encrypt/src/key-provider-akv.ts @@ -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 { + 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); + } +} diff --git a/packages/field-encrypt/src/key-provider-env.ts b/packages/field-encrypt/src/key-provider-env.ts new file mode 100644 index 00000000..4b7de088 --- /dev/null +++ b/packages/field-encrypt/src/key-provider-env.ts @@ -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 { + 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; + } +} diff --git a/packages/field-encrypt/src/key-provider-memory.ts b/packages/field-encrypt/src/key-provider-memory.ts new file mode 100644 index 00000000..e5867984 --- /dev/null +++ b/packages/field-encrypt/src/key-provider-memory.ts @@ -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 { + 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; + } +} diff --git a/packages/field-encrypt/src/migration.ts b/packages/field-encrypt/src/migration.ts new file mode 100644 index 00000000..978c4ac4 --- /dev/null +++ b/packages/field-encrypt/src/migration.ts @@ -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 { + /** Fetch a batch of documents. Return empty array when done. */ + fetchBatch: (offset: number, batchSize: number) => Promise; + /** 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; + /** Write the encrypted value back to the store. */ + writeBack: (doc: T, encrypted: EncryptedField) => Promise; + /** 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( + options: MigrateDocumentsOptions +): Promise { + 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; +} diff --git a/packages/field-encrypt/src/types.ts b/packages/field-encrypt/src/types.ts new file mode 100644 index 00000000..e882f612 --- /dev/null +++ b/packages/field-encrypt/src/types.ts @@ -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; +} + +/** 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; + /** Store a wrapped DEK. */ + put(dek: WrappedDek): Promise; + /** List all DEK IDs (for rotation). */ + listIds(): Promise; + /** Delete a DEK. */ + delete(dekId: string): Promise; +} + +/** 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; +} diff --git a/packages/field-encrypt/tsconfig.json b/packages/field-encrypt/tsconfig.json new file mode 100644 index 00000000..5edad813 --- /dev/null +++ b/packages/field-encrypt/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/scripts/create-encryption-keys.sh b/scripts/create-encryption-keys.sh new file mode 100755 index 00000000..e9f0bc4f --- /dev/null +++ b/scripts/create-encryption-keys.sh @@ -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."