import 'server-only' import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' import { env } from '@/lib/env' /** * Anwendungsseitiger Umschlag fuer abgelegte Dateien (AES-256-GCM). * * Der Dateitreiber hat das von Anfang an getan, der Objektspeicher nicht: * Dort stand nur ServerSideEncryption: 'AES256'. Das ist Verschluesselung * mit dem Schluessel des Anbieters - er entschluesselt beim Ausliefern * selbst. Gegen ein kopiertes Backup hilft es, gegen den Anbieter und gegen * gestohlene Zugangsdaten zum Eimer nicht. Bei Bescheiden, Arztbriefen und * Kontoauszuegen ist das der falsche Zuschnitt. * * Jetzt liegt derselbe Umschlag um beides. Der Schluessel kommt aus der * Umgebung und nie aus der Datenbank oder vom Anbieter. * * ## Format * * "BCD1" | Schluesselversion (uint32 BE) | IV (12) | AuthTag (16) | Geheimtext * * Bewusst dasselbe Format wie bisher im Dateitreiber - bestehende Dateien * bleiben ohne Umschreiben lesbar. * * ## Zusatzdaten * * Mitauthentifiziert wird die Schluesselversion und, wo der Aufrufer ihn * angibt, der Ablageschluessel des Objekts. Letzteres bindet den Geheimtext * an seinen Platz: Wer Schreibrechte auf den Eimer hat, kann die Bytes von * Dokument A nicht unter dem Namen von Dokument B ablegen - beim Lesen * schlaegt die Pruefung fehl, statt den falschen Bescheid auszuliefern. * * Ohne Angabe bleibt es bei "bcd:", damit die Dateien, die der * Dateitreiber frueher geschrieben hat, weiter aufgehen. */ const MAGIE = Buffer.from('BCD1', 'ascii') const IV_LAENGE = 12 const TAG_LAENGE = 16 export const KOPF_LAENGE = MAGIE.length + 4 + IV_LAENGE + TAG_LAENGE function zusatzdaten(version: number, ablageschluessel?: string): Buffer { return Buffer.from( ablageschluessel ? `bcd:${version}:${ablageschluessel}` : `bcd:${version}`, 'utf8', ) } /** Traegt dieser Inhalt bereits einen Umschlag? */ export function istVerschluesselt(roh: Buffer): boolean { return roh.length >= KOPF_LAENGE && roh.subarray(0, MAGIE.length).equals(MAGIE) } export function verschluesselnBinaer(inhalt: Buffer, ablageschluessel?: string): Buffer { const version = env.aktiveSchluesselversion const schluessel = env.verschluesselungsschluessel.get(version) if (!schluessel) throw new Error(`Verschlüsselungsschlüssel Version ${version} fehlt.`) const iv = randomBytes(IV_LAENGE) const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE }) cipher.setAAD(zusatzdaten(version, ablageschluessel)) const geheim = Buffer.concat([cipher.update(inhalt), cipher.final()]) const versionBytes = Buffer.alloc(4) versionBytes.writeUInt32BE(version, 0) return Buffer.concat([MAGIE, versionBytes, iv, cipher.getAuthTag(), geheim]) } /** * Nimmt den Umschlag ab. * * Inhalte ohne Umschlag werden unveraendert durchgereicht: Dateien aus * einer aelteren Installation, von Hand hineingelegte, und - bis der * Migrationslauf durch ist - die Objekte, die vor dieser Aenderung im * Objektspeicher lagen. */ export function entschluesselnBinaer(roh: Buffer, ablageschluessel?: string): Buffer { if (!istVerschluesselt(roh)) return roh const version = roh.readUInt32BE(MAGIE.length) const schluessel = env.verschluesselungsschluessel.get(version) if (!schluessel) { throw new Error( `Zum Entschlüsseln dieser Datei fehlt ENC_KEY_V${version}. Alte Schlüssel dürfen beim ` + `Rotieren nicht aus der Umgebung entfernt werden.`, ) } const ivVon = MAGIE.length + 4 const iv = roh.subarray(ivVon, ivVon + IV_LAENGE) const tag = roh.subarray(ivVon + IV_LAENGE, ivVon + IV_LAENGE + TAG_LAENGE) const geheim = roh.subarray(KOPF_LAENGE) // Zwei Anlaeufe: erst mit dem Ablageschluessel als Zusatzdaten, dann ohne. // Der zweite Anlauf ist fuer alles, was vor der Bindung an den Platz // geschrieben wurde - ohne ihn waeren die alten Dateien nicht mehr // lesbar, und das waere ein Datenverlust fuer eine Verbesserung. const varianten = ablageschluessel ? [ablageschluessel, undefined] : [undefined] let letzterFehler: unknown = null for (const variante of varianten) { try { const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE, }) decipher.setAAD(zusatzdaten(version, variante)) decipher.setAuthTag(tag) return Buffer.concat([decipher.update(geheim), decipher.final()]) } catch (fehler) { letzterFehler = fehler } } throw letzterFehler instanceof Error ? letzterFehler : new Error('Die Datei ließ sich nicht entschlüsseln.') }