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