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>
This commit is contained in:
Florian Wilde
2026-09-10 18:04:41 +02:00
co-authored by Claude Opus 5
parent a6bcaf8982
commit 79e6c0d556
4 changed files with 254 additions and 3 deletions
+84
View File
@@ -0,0 +1,84 @@
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
}