import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; /** * Encryption algorithm */ const ALGORITHM = 'aes-256-gcm'; /** * IV (Initialization Vector) length in bytes */ const IV_LENGTH = 16; /** * Auth tag length in bytes */ const AUTH_TAG_LENGTH = 16; /** * Encrypted secret data */ export interface EncryptedSecret { encryptedValue: string; iv: string; authTag: string; } /** * Encrypt a secret value * * Policy function (Rule 10.1) - pure computation, no I/O * * @param plaintext - Secret value to encrypt * @param masterKey - 32-byte master key * @returns Encrypted data with IV and auth tag */ export function encryptSecret(plaintext: string, masterKey: Buffer): EncryptedSecret { if (!Buffer.isBuffer(masterKey) || masterKey.length !== 32) { throw new Error('Master key must be 32 bytes'); } if (typeof plaintext !== 'string') { throw new Error('Plaintext must be a string'); } // Generate random IV const iv = randomBytes(IV_LENGTH); // Create cipher const cipher = createCipheriv(ALGORITHM, masterKey, iv); // Encrypt let encrypted = cipher.update(plaintext, 'utf8', 'hex'); encrypted += cipher.final('hex'); // Get auth tag const authTag = cipher.getAuthTag(); return { encryptedValue: encrypted, iv: iv.toString('hex'), authTag: authTag.toString('hex'), }; } /** * Decrypt a secret value * * Policy function - pure computation, no I/O * * @param encrypted - Encrypted secret data * @param masterKey - 32-byte master key * @returns Decrypted plaintext value * @throws Error if decryption fails (wrong key, corrupted data, etc.) */ export function decryptSecret(encrypted: EncryptedSecret, masterKey: Buffer): string { if (!Buffer.isBuffer(masterKey) || masterKey.length !== 32) { throw new Error('Master key must be 32 bytes'); } // Validate input (allow empty encryptedValue for empty plaintext) if ( encrypted.encryptedValue === undefined || encrypted.encryptedValue === null || !encrypted.iv || !encrypted.authTag ) { throw new Error('Invalid encrypted secret: missing required fields'); } try { // Parse hex strings to buffers const iv = Buffer.from(encrypted.iv, 'hex'); const authTag = Buffer.from(encrypted.authTag, 'hex'); if (iv.length !== IV_LENGTH) { throw new Error(`Invalid IV length: expected ${IV_LENGTH}, got ${iv.length}`); } if (authTag.length !== AUTH_TAG_LENGTH) { throw new Error( `Invalid auth tag length: expected ${AUTH_TAG_LENGTH}, got ${authTag.length}`, ); } // Create decipher const decipher = createDecipheriv(ALGORITHM, masterKey, iv); decipher.setAuthTag(authTag); // Decrypt let decrypted = decipher.update(encrypted.encryptedValue, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } catch (error) { // Re-throw with clearer message const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to decrypt secret: ${message}`); } } /** * Validate encrypted secret format * * Policy function - validation only * * @param encrypted - Object to validate * @returns True if has all required fields with valid format */ export function isValidEncryptedSecret(encrypted: unknown): encrypted is EncryptedSecret { if (typeof encrypted !== 'object' || encrypted === null) { return false; } const secret = encrypted as Partial; // Check required fields exist and are strings if ( typeof secret.encryptedValue !== 'string' || typeof secret.iv !== 'string' || typeof secret.authTag !== 'string' ) { return false; } // Check hex format (basic validation) // Note: encryptedValue can be empty for empty plaintext const hexPattern = /^[0-9a-f]*$/i; if ( !hexPattern.test(secret.encryptedValue) || !hexPattern.test(secret.iv) || !hexPattern.test(secret.authTag) ) { return false; } // Check lengths (IV and auth tag are fixed length in hex) if (secret.iv.length !== IV_LENGTH * 2 || secret.authTag.length !== AUTH_TAG_LENGTH * 2) { return false; } return true; }