import 'server-only' import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'node:crypto' import { env } from '@/lib/env' /** * Anwendungsseitige Feldverschluesselung mit AES-256-GCM. * * Warum zusaetzlich zur Festplattenverschluesselung? Weil ein Datenbank-Dump, * ein Backup auf fremdem Speicher oder ein kompromittierter Datenbankzugang * sonst Diagnosen, IBANs und Notizen im Klartext preisgibt. Der Schluessel * liegt in der Umgebung, nicht in der Datenbank - beides zusammen wird * gebraucht, um die Daten zu lesen. * * Umschlagformat (ASCII, punktgetrennt, base64url ohne Padding): * * bc1.... * * "bc1" = Betreuungs-Cockpit, Formatversion 1. Die Schluesselversion erlaubt * Rotation: neue Werte werden mit ENC_KEY_AKTIV verschluesselt, alte bleiben * mit ihrem Schluessel lesbar. * * Zusatzdaten (AAD): Der Feldname wird mitauthentifiziert. Dadurch laesst * sich ein Geheimtext nicht von einem Feld in ein anderes kopieren - * "diagnosen" bleibt "diagnosen". */ const PRAEFIX = 'bc1' const IV_LAENGE = 12 // 96 Bit, der von NIST empfohlene Wert fuer GCM const TAG_LAENGE = 16 function b64url(buf: Buffer): string { return buf.toString('base64url') } function ausB64url(text: string): Buffer { return Buffer.from(text, 'base64url') } /** * Verschluesselt einen Klartext. Gibt null zurueck, wenn nichts zu * verschluesseln ist - so bleiben leere Felder in der Datenbank NULL und * verraten nicht einmal, dass etwas eingetragen war. */ export function verschluesseln(klartext: string | null | undefined, kontext = ''): string | null { if (klartext === null || klartext === undefined || klartext === '') return null const version = env.aktiveSchluesselversion const schluessel = env.verschluesselungsschluessel.get(version) if (!schluessel) { throw new Error(`Verschluesselungsschluessel Version ${version} nicht verfuegbar.`) } const iv = randomBytes(IV_LAENGE) const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE }) cipher.setAAD(Buffer.from(`${PRAEFIX}:${version}:${kontext}`, 'utf8')) const geheimtext = Buffer.concat([cipher.update(klartext, 'utf8'), cipher.final()]) const tag = cipher.getAuthTag() return [PRAEFIX, version, b64url(iv), b64url(tag), b64url(geheimtext)].join('.') } /** * Entschluesselt einen Umschlag. Wirft, wenn der Geheimtext manipuliert * wurde oder der Schluessel nicht passt - stilles Zurueckfallen auf den * Rohwert waere hier gefaehrlich. */ export function entschluesseln(umschlag: string | null | undefined, kontext = ''): string | null { if (umschlag === null || umschlag === undefined || umschlag === '') return null const teile = umschlag.split('.') if (teile.length !== 5 || teile[0] !== PRAEFIX) { throw new Error( 'Ungueltiger Umschlag: erwartet wurde das Format bc1.....', ) } const version = Number.parseInt(teile[1]!, 10) const schluessel = env.verschluesselungsschluessel.get(version) if (!schluessel) { throw new Error( `Zum Entschluesseln fehlt ENC_KEY_V${version}. Der Schluessel darf beim ` + `Rotieren nicht aus der Umgebung entfernt werden, solange noch Daten ` + `mit ihm verschluesselt sind.`, ) } const iv = ausB64url(teile[2]!) const tag = ausB64url(teile[3]!) const geheimtext = ausB64url(teile[4]!) const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE }) decipher.setAAD(Buffer.from(`${PRAEFIX}:${version}:${kontext}`, 'utf8')) decipher.setAuthTag(tag) return Buffer.concat([decipher.update(geheimtext), decipher.final()]).toString('utf8') } /** Entschluesselt, gibt aber bei Fehlern null zurueck statt zu werfen. */ export function entschluesselnWennMoeglich( umschlag: string | null | undefined, kontext = '', ): string | null { try { return entschluesseln(umschlag, kontext) } catch { return null } } /** Erkennt, ob ein Wert bereits ein Umschlag ist (fuer Migrationen). */ export function istUmschlag(wert: unknown): wert is string { return typeof wert === 'string' && wert.startsWith(`${PRAEFIX}.`) && wert.split('.').length === 5 } /** Liest die Schluesselversion aus einem Umschlag - fuer die Rotation. */ export function schluesselversionVon(umschlag: string): number | null { if (!istUmschlag(umschlag)) return null return Number.parseInt(umschlag.split('.')[1]!, 10) } /** * Verschluesselt einen bestehenden Umschlag mit dem aktuell aktiven * Schluessel neu. Gibt null zurueck, wenn er bereits aktuell ist. * Wird vom Rotationsskript benutzt. */ export function neuVerschluesseln(umschlag: string, kontext = ''): string | null { if (schluesselversionVon(umschlag) === env.aktiveSchluesselversion) return null const klartext = entschluesseln(umschlag, kontext) return verschluesseln(klartext, kontext) } // ----------------------------------------------------------------------------- // Bequeme Helfer fuer ganze Datensaetze // ----------------------------------------------------------------------------- /** * Liste der verschluesselten Felder je Modell. Einziger Ort, an dem steht, * was verschluesselt gehoert - Grundlage fuer sealen/oeffnen und fuer den * Test, der prueft, dass nichts vergessen wurde. */ export const VERSCHLUESSELTE_FELDER = { Betreute: [ 'krankenversichertennummerVerschluesselt', 'diagnosenVerschluesselt', 'ermittelterWilleVerschluesselt', 'rentenversicherungsnummerVerschluesselt', 'steuerIdVerschluesselt', 'notizenVerschluesselt', ], User: ['totpSecretVerschluesselt'], Vorgang: ['beschreibungVerschluesselt'], Genehmigung: ['notizenVerschluesselt'], Dokument: ['notizenVerschluesselt'], Konto: ['ibanVerschluesselt'], Buchung: ['verwendungszweckVerschluesselt'], Vermoegensposten: ['bewertungBegruendungVerschluesselt', 'notizenVerschluesselt'], Immobilie: ['statusBegruendungVerschluesselt', 'notizenVerschluesselt'], Vertrag: [ 'vertragsnummerVerschluesselt', 'bewertungBegruendungVerschluesselt', 'notizenVerschluesselt', ], Kontakt: ['notizenVerschluesselt'], Kontaktprotokoll: [ 'inhaltVerschluesselt', 'wuenscheVerschluesselt', 'folgeaufgabenVerschluesselt', ], Gesundheitsereignis: ['beschreibungVerschluesselt'], Heimvertrag: ['notizenVerschluesselt'], SozialhilfeAntrag: ['notizenVerschluesselt'], Bescheid: ['notizenVerschluesselt'], Bericht: ['entwurfVerschluesselt'], AuditLog: ['aenderungVerschluesselt'], } as const export type VerschluesseltesModell = keyof typeof VERSCHLUESSELTE_FELDER /** Aus "diagnosenVerschluesselt" wird "diagnosen". */ type OhneSuffix = F extends `${infer Name}Verschluesselt` ? Name : never /** * Die Klartextfelder eines Modells als benannte Eigenschaften. * * Ohne diesen Typ waere das Ergebnis von oeffnen() nur ein * Record. Mit noUncheckedIndexedAccess bekaeme * jeder Zugriff darauf zusaetzlich "undefined" - und jede aufrufende * Seite muesste das wegkaesen, obwohl die Felder tatsaechlich immer da * sind. */ export type Klartextfelder = { [K in OhneSuffix<(typeof VERSCHLUESSELTE_FELDER)[M][number]>]: string | null } /** * Nimmt ein Objekt mit Klartextfeldern (ohne Suffix) und liefert die * verschluesselten Felder (mit Suffix). Beispiel: * * sealen('Betreute', { diagnosen: 'COPD GOLD III' }) * -> { diagnosenVerschluesselt: 'bc1.1....' } * * Felder, die im Eingabeobjekt fehlen, tauchen im Ergebnis nicht auf - * ein Prisma-Update laesst sie damit unangetastet. */ export function sealen( modell: VerschluesseltesModell, klartextfelder: Record, ): Record { const ergebnis: Record = {} for (const [name, wert] of Object.entries(klartextfelder)) { if (wert === undefined) continue const zielfeld = `${name}Verschluesselt` if (!(VERSCHLUESSELTE_FELDER[modell] as readonly string[]).includes(zielfeld)) { throw new Error(`${modell}.${zielfeld} ist nicht als verschluesseltes Feld eingetragen.`) } ergebnis[zielfeld] = verschluesseln(wert, zielfeld) } return ergebnis } /** * Gegenstueck zu sealen: nimmt einen Datensatz aus der Datenbank und * ergaenzt die entschluesselten Klartextfelder ohne Suffix. Die * Umschlagfelder bleiben stehen, damit nichts versehentlich zurueck- * geschrieben wird. */ export function oeffnen>( modell: M, datensatz: T, ): T & Klartextfelder { // Zwischenschritt ueber ein einfaches Objekt: ein generischer Typ laesst // sich in TypeScript nicht per Index beschreiben. const ergebnis: Record = { ...datensatz } for (const feld of VERSCHLUESSELTE_FELDER[modell] as readonly string[]) { const klarname = feld.replace(/Verschluesselt$/, '') ergebnis[klarname] = (feld in datensatz) ? entschluesselnWennMoeglich(datensatz[feld] as string | null, feld) : null } return ergebnis as T & Klartextfelder } /** Konstantzeit-Vergleich zweier Zeichenketten. */ export function gleichSicher(a: string, b: string): boolean { const bufA = Buffer.from(a, 'utf8') const bufB = Buffer.from(b, 'utf8') if (bufA.length !== bufB.length) return false return timingSafeEqual(bufA, bufB) }