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>
85 lines
2.8 KiB
TypeScript
85 lines
2.8 KiB
TypeScript
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
|
||
}
|