import crypto from 'node:crypto' import { xchacha20poly1305 } from '@noble/ciphers/chacha.js' /** * Authenticated encryption with a key from runtime config (`NUXT_ENCRYPTION_KEY`, * base64-encoded 32 bytes). Used to wrap anything sensitive at the app layer * before it lands in the DB: AT Proto session blobs, SSH private keys. * * The KEK is held only in env. If it's lost, every encrypted row becomes * unreadable. KEK rotation is a future concern; see PLAN.md. */ const NONCE_BYTES = 24 let cachedKey: Uint8Array | undefined function getKey(): Uint8Array { if (cachedKey) return cachedKey // Read process.env directly rather than via useRuntimeConfig() so this helper // is callable from outside a Nitro request context (e.g. tests, scripts). // Nuxt's runtime config still declares the var for documentation; the env // name is the same. const raw = process.env.NUXT_ENCRYPTION_KEY if (!raw) { throw new Error('NUXT_ENCRYPTION_KEY is not set (expected base64-encoded 32 bytes)') } const decoded = Buffer.from(raw, 'base64') if (decoded.length !== 32) { throw new Error(`NUXT_ENCRYPTION_KEY must decode to 32 bytes, got ${decoded.length}`) } cachedKey = new Uint8Array(decoded) return cachedKey } export function encrypt(plaintext: string): { ciphertext: Buffer, nonce: Buffer } { const nonce = crypto.randomBytes(NONCE_BYTES) const cipher = xchacha20poly1305(getKey(), new Uint8Array(nonce)) const ciphertext = cipher.encrypt(new TextEncoder().encode(plaintext)) return { ciphertext: Buffer.from(ciphertext), nonce } } export function decrypt(ciphertext: Buffer, nonce: Buffer): string { const cipher = xchacha20poly1305(getKey(), new Uint8Array(nonce)) const plaintext = cipher.decrypt(new Uint8Array(ciphertext)) return new TextDecoder().decode(plaintext) } /** Test/utility hook: drop the cached key so the next call re-reads runtime config. */ export function clearEncryptionKeyCache() { cachedKey = undefined }