// ── §9-Verschlüsselung der Tenant-Artefakte (client-seitig, vor dem Upload) ─── // // Entscheidung (KONZEPT §9): client-seitige AES-256-Verschlüsselung mit EINEM // Schlüssel pro Umgebung — das Artefakt ist verschlüsselt, BEVOR es MinIO/S3 // erreicht (zero-knowledge vom Speicher, at-rest + in-transit). Konsistent zur // bestehenden TOTP-Verschlüsselung (AES-256-GCM, src/server/secret-crypto.ts). // // Schlüssel: `BACKUP_ENC_KEY` (dediziert, pro Umgebung), Fallback `AUTH_SECRET`. // Der Schlüssel liegt NIE im Artefakt (Restore-Kohärenz-Regel, KONZEPT §9): // Pepper/MFA_ENC_KEY/BACKUP_ENC_KEY sind Umgebungs-Secrets, kein Artefakt-Inhalt. // // Blob-Layout (nach der Kompression): // magic "CVB1" | iv(12) | authTag(16) | ciphertext(gzip(plaintext)) import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; import { gzipSync, gunzipSync } from "node:zlib"; const MAGIC = Buffer.from("CVB1", "ascii"); function backupKey(): Buffer { const material = process.env.BACKUP_ENC_KEY || process.env.AUTH_SECRET; if (!material) { throw new Error( "BACKUP_ENC_KEY/AUTH_SECRET fehlt — Tenant-Artefakte können nicht verschlüsselt werden. " + "Pro Umgebung eindeutig setzen; NIEMALS im selben Bucket wie die Artefakte ablegen (KONZEPT §9).", ); } return createHash("sha256").update(`${material}:tenant-backup`).digest(); // 32 Byte } /** Komprimiert (gzip) und verschlüsselt (AES-256-GCM) den Klartext-Body. */ export function sealArtifact(plaintext: string): Buffer { const gz = gzipSync(Buffer.from(plaintext, "utf8")); const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", backupKey(), iv); const ct = Buffer.concat([cipher.update(gz), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([MAGIC, iv, tag, ct]); } /** Entschlüsselt und dekomprimiert ein von `sealArtifact` erzeugtes Blob. */ export function openArtifact(blob: Buffer): string { if (blob.length < MAGIC.length + 12 + 16 || !blob.subarray(0, 4).equals(MAGIC)) { throw new Error("Ungültiges/beschädigtes Backup-Artefakt (Magic-Byte-Prüfung fehlgeschlagen)."); } let off = MAGIC.length; const iv = blob.subarray(off, off + 12); off += 12; const tag = blob.subarray(off, off + 16); off += 16; const ct = blob.subarray(off); const decipher = createDecipheriv("aes-256-gcm", backupKey(), iv); decipher.setAuthTag(tag); const gz = Buffer.concat([decipher.update(ct), decipher.final()]); return gunzipSync(gz).toString("utf8"); }