/** * AES-256-GCM encryption for tenant package credentials. * * Credentials are encrypted before storage in tenant_requests.encrypted_secrets * and decrypted only during admin approval to write to OpenBao tenant paths. * * Format: [12-byte IV][ciphertext][16-byte auth tag] as a single Buffer. * * Provision the key: * bao kv put pieced/portal/encryption-key key="$(openssl rand -hex 32)" */ import { randomBytes, createCipheriv, createDecipheriv } from "crypto"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; const TAG_LENGTH = 16; let cachedKey: Buffer | null = null; async function getEncryptionKey(): Promise { if (cachedKey) return cachedKey; const { readSecret } = await import("./openbao"); const data = await readSecret("pieced/portal/encryption-key"); const hex = data?.key; if (!hex || typeof hex !== "string" || hex.length !== 64) { throw new Error( "Invalid encryption key at secret/data/pieced/portal/encryption-key" ); } cachedKey = Buffer.from(hex, "hex"); return cachedKey; } export async function encryptSecrets( secrets: Record> ): Promise { const key = await getEncryptionKey(); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); const plaintext = JSON.stringify(secrets); const encrypted = Buffer.concat([ cipher.update(plaintext, "utf8"), cipher.final(), ]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, encrypted, tag]); } export async function decryptSecrets( blob: Buffer ): Promise>> { const key = await getEncryptionKey(); const iv = blob.subarray(0, IV_LENGTH); const tag = blob.subarray(blob.length - TAG_LENGTH); const ciphertext = blob.subarray(IV_LENGTH, blob.length - TAG_LENGTH); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(tag); const decrypted = Buffer.concat([ decipher.update(ciphertext), decipher.final(), ]); return JSON.parse(decrypted.toString("utf8")); }