- 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
63 lines
2.1 KiB
TypeScript
63 lines
2.1 KiB
TypeScript
/**
|
|
* @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;
|
|
}
|
|
}
|