Files
craftvia/src/server/backup/crypto.ts
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

57 lines
2.5 KiB
TypeScript

// ── §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");
}