/** * @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); }