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:
co-authored by
Claude Opus 5
parent
a6bcaf8982
commit
79e6c0d556
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user