import 'server-only' import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto' import { umgebung } from '@/server/umgebung' /** * Verschlüsselung für Zugangs- und Aktualisierungstoken der Kalenderkonten. * * Wer die Datenbank oder eine Sicherung in die Hände bekommt, hätte sonst * direkten Zugriff auf fremde Kalender und Postfächer. Das wiegt schwerer als * bei den OIDC-Tokens aus M1, die nur die Anmeldung betreffen. * * Der Schlüssel wird aus AUTH_SECRET abgeleitet, nicht separat konfiguriert: * eine zusätzliche Pflichtvariable wäre eine weitere Stelle, an der bei der * Einrichtung etwas schiefgeht. Wer AUTH_SECRET wechselt, muss die * Kalenderkonten neu verbinden – das ist gewollt, denn mit dem alten Geheimnis * sind auch die alten Sitzungen ungültig. */ const ALGORITHMUS = 'aes-256-gcm' const SCHLUESSEL_LAENGE = 32 const IV_LAENGE = 12 const MARKE = 'v1' let zwischengespeichert: Buffer | null = null function schluessel(): Buffer { if (zwischengespeichert) return zwischengespeichert const abgeleitet = hkdfSync( 'sha256', Buffer.from(umgebung().AUTH_SECRET, 'utf8'), Buffer.alloc(0), Buffer.from('planer:kalender-token', 'utf8'), SCHLUESSEL_LAENGE, ) zwischengespeichert = Buffer.from(abgeleitet) return zwischengespeichert } /** * Verschlüsselt einen Text. Das Ergebnis ist selbstbeschreibend: * "v1:::", alles base64url. */ export function verschluesseln(klartext: string): string { const iv = randomBytes(IV_LAENGE) const chiffre = createCipheriv(ALGORITHMUS, schluessel(), iv) const inhalt = Buffer.concat([chiffre.update(klartext, 'utf8'), chiffre.final()]) const echtheitsmarke = chiffre.getAuthTag() return [ MARKE, iv.toString('base64url'), echtheitsmarke.toString('base64url'), inhalt.toString('base64url'), ].join(':') } /** * Entschlüsselt einen Text. Gibt null zurück, wenn er nicht zum Schlüssel * passt oder verändert wurde – das ist kein Absturzgrund, sondern heißt: * das Konto muss neu verbunden werden. */ export function entschluesseln(gespeichert: string | null): string | null { if (!gespeichert) return null const teile = gespeichert.split(':') if (teile.length !== 4 || teile[0] !== MARKE) return null try { const iv = Buffer.from(teile[1] ?? '', 'base64url') const echtheitsmarke = Buffer.from(teile[2] ?? '', 'base64url') const inhalt = Buffer.from(teile[3] ?? '', 'base64url') const dechiffre = createDecipheriv(ALGORITHMUS, schluessel(), iv) dechiffre.setAuthTag(echtheitsmarke) return Buffer.concat([dechiffre.update(inhalt), dechiffre.final()]).toString('utf8') } catch { return null } } /** Nur für Tests: erzwingt beim nächsten Aufruf ein erneutes Ableiten. */ export function schluesselVergessen(): void { zwischengespeichert = null }