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:
parent
8af997ba0f
commit
bb3f5385fc
@ -71,5 +71,13 @@ TELEMETRY_ALERT_WEBHOOK_URL=
|
|||||||
TELEMETRY_GEO_API_URL=http://ip-api.com/json
|
TELEMETRY_GEO_API_URL=http://ip-api.com/json
|
||||||
TELEMETRY_EVENT_TTL_DAYS=90
|
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 ──────────────────────────────────────────
|
# ── Product Identity ──────────────────────────────────────────
|
||||||
DEFAULT_PRODUCT_ID=lysnrai
|
DEFAULT_PRODUCT_ID=lysnrai
|
||||||
|
|||||||
40
packages/field-encrypt/package.json
Normal file
40
packages/field-encrypt/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
89
packages/field-encrypt/src/aes-gcm.ts
Normal file
89
packages/field-encrypt/src/aes-gcm.ts
Normal 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);
|
||||||
|
}
|
||||||
27
packages/field-encrypt/src/dek-store-memory.ts
Normal file
27
packages/field-encrypt/src/dek-store-memory.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
packages/field-encrypt/src/envelope.ts
Normal file
107
packages/field-encrypt/src/envelope.ts
Normal 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;
|
||||||
|
}
|
||||||
171
packages/field-encrypt/src/field-encryptor.ts
Normal file
171
packages/field-encrypt/src/field-encryptor.ts
Normal 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);
|
||||||
|
}
|
||||||
27
packages/field-encrypt/src/guards.ts
Normal file
27
packages/field-encrypt/src/guards.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
429
packages/field-encrypt/src/index.test.ts
Normal file
429
packages/field-encrypt/src/index.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
packages/field-encrypt/src/index.ts
Normal file
57
packages/field-encrypt/src/index.ts
Normal 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';
|
||||||
94
packages/field-encrypt/src/key-cache.ts
Normal file
94
packages/field-encrypt/src/key-cache.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
packages/field-encrypt/src/key-provider-akv.ts
Normal file
68
packages/field-encrypt/src/key-provider-akv.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/field-encrypt/src/key-provider-env.ts
Normal file
62
packages/field-encrypt/src/key-provider-env.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
packages/field-encrypt/src/key-provider-memory.ts
Normal file
48
packages/field-encrypt/src/key-provider-memory.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
packages/field-encrypt/src/migration.ts
Normal file
110
packages/field-encrypt/src/migration.ts
Normal 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;
|
||||||
|
}
|
||||||
84
packages/field-encrypt/src/types.ts
Normal file
84
packages/field-encrypt/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
9
packages/field-encrypt/tsconfig.json
Normal file
9
packages/field-encrypt/tsconfig.json
Normal 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
122
scripts/create-encryption-keys.sh
Executable 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."
|
||||||
Loading…
Reference in New Issue
Block a user