- 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
90 lines
2.4 KiB
TypeScript
90 lines
2.4 KiB
TypeScript
/**
|
|
* @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);
|
|
}
|