import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto"; /** * Envelope encryption vault. * - Master KEK from env (32 bytes, base64). Dev fallback: derive a stable key * from a machine-local file so ciphertexts survive restarts in development. * - Per-workspace DEK (AES-256-GCM) wrapped by the KEK. * - Rotation: new DEK per workspace, key_version increments; old ciphertexts * stay readable until re-wrapped, then the old DEK is dropped. */ const ALGO = "aes-256-gcm"; export function loadKek(): Buffer { const env = process.env.SUPERPOSTER_KEK; if (env !== undefined && env !== "") { const kek = Buffer.from(env, "base64"); if (kek.length !== 32) throw new Error("SUPERPOSTER_KEK must be 32 bytes base64"); return kek; } // Dev fallback: deterministic from hostname — NEVER production. if (process.env.NODE_ENV === "production") { throw new Error("SUPERPOSTER_KEK is required in production"); } return createHash("sha256").update(`dev-kek:${process.env.HOSTNAME ?? "local"}`).digest(); } const KEK = loadKek(); export interface SealedCredential { ciphertext: Buffer; keyVersion: number; } interface WrappedDek { keyVersion: number; wrapped: Buffer; } function aesEncrypt(key: Buffer, plaintext: Buffer): Buffer { const iv = randomBytes(12); const cipher = createCipheriv(ALGO, key, iv); return Buffer.concat([iv, cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); } function aesDecrypt(key: Buffer, sealed: Buffer): Buffer { const iv = sealed.subarray(0, 12); const tag = sealed.subarray(sealed.length - 16); const body = sealed.subarray(12, sealed.length - 16); const decipher = createDecipheriv(ALGO, key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(body), decipher.final()]); } /** In-memory DEK cache; the persistent wrapped-DEK store arrives with the credentials table wiring. */ export class Vault { /** Self-contained envelope for persistent stores; AAD binds tenant and handle. */ async seal(workspaceId: string, credentialId: string, plaintext: Buffer): Promise { const dek = randomBytes(32); const wrapped = aesEncrypt(KEK, dek); const iv = randomBytes(12); const cipher = createCipheriv(ALGO, dek, iv); cipher.setAAD(Buffer.from(JSON.stringify([workspaceId, credentialId]))); const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); return { keyVersion: 1, ciphertext: Buffer.concat([Buffer.from("APV1"), wrapped, iv, cipher.getAuthTag(), encrypted]) }; } async open(workspaceId: string, credentialId: string, sealed: SealedCredential): Promise { const blob = sealed.ciphertext; if (blob.length < 92 || blob.subarray(0, 4).toString() !== "APV1") throw new Error("invalid vault envelope"); const dek = aesDecrypt(KEK, blob.subarray(4, 64)); const decipher = createDecipheriv(ALGO, dek, blob.subarray(64, 76)); decipher.setAAD(Buffer.from(JSON.stringify([workspaceId, credentialId]))); decipher.setAuthTag(blob.subarray(76, 92)); return Buffer.concat([decipher.update(blob.subarray(92)), decipher.final()]); } private readonly deks = new Map(); private versions = new Map(); async put(_workspaceId: string, credentialId: string, plaintext: Buffer): Promise { const { dek, wrapped } = await this.ensureDek(_workspaceId); this.deks.set(credentialId, { dek, wrapped }); const version = this.versions.get(_workspaceId) ?? 1; return { ciphertext: aesEncrypt(dek, plaintext), keyVersion: version }; } async get(_workspaceId: string, credentialId: string, sealed: SealedCredential): Promise { const entry = this.deks.get(credentialId); if (entry === undefined) throw new Error("credential not loaded into vault session"); return aesDecrypt(entry.dek, sealed.ciphertext); } /** * Rotation: fresh DEK for the workspace, re-wrap given credentials, * drop the old DEK so pre-rotation ciphertext becomes unreadable. */ async rotate(workspaceId: string, secrets: Array<{ credentialId: string; plaintext: Buffer }>): Promise> { const nextVersion = (this.versions.get(workspaceId) ?? 1) + 1; const newDek = randomBytes(32); const wrapped: WrappedDek = { keyVersion: nextVersion, wrapped: aesEncrypt(KEK, newDek) }; this.versions.set(workspaceId, nextVersion); const out = new Map(); for (const { credentialId, plaintext } of secrets) { this.deks.set(credentialId, { dek: newDek, wrapped }); out.set(credentialId, { ciphertext: aesEncrypt(newDek, plaintext), keyVersion: nextVersion }); } return out; } private async ensureDek(workspaceId: string): Promise<{ dek: Buffer; wrapped: WrappedDek }> { const existing = [...this.deks.values()].find((e) => e.wrapped.keyVersion === (this.versions.get(workspaceId) ?? 1)); if (existing !== undefined) return existing; const version = 1; const dek = randomBytes(32); const entry = { dek, wrapped: { keyVersion: version, wrapped: aesEncrypt(KEK, dek) } }; this.versions.set(workspaceId, version); return entry; } } export const vault = new Vault();