Files
planer/src/server/krypto.ts
T
Florian WildeandClaude Opus 5 79e6c0d556 feat(kalender): Datenmodell für Kalenderkonten und Termine
Kalenderkonto mit Tokens und Abgleichverweis, Termin als örtlicher Spiegel
mit genau so viel, wie die Belegungsrechnung und die Wochenansicht
brauchen. Bereich bekommt sein Standard-Kalenderkonto für M4.

Die Tokens liegen verschlüsselt (AES-256-GCM). Wer die Datenbank oder eine
Sicherung in die Hände bekommt, hätte sonst direkten Zugriff auf fremde
Kalender — das wiegt schwerer als bei den OIDC-Tokens aus M1, die nur die
Anmeldung betreffen. Der Schlüssel wird aus AUTH_SECRET abgeleitet statt
separat konfiguriert: eine weitere Pflichtvariable wäre eine weitere
Stelle, an der die Einrichtung scheitert.

kalenderId ist nicht optional, sondern trägt den Wert "standard" für den
Standardkalender — Prisma lässt in einem zusammengesetzten Unique-Schlüssel
kein null zu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:04:41 +02:00

85 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<iv>:<marke>:<inhalt>", 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
}