feat: Feldverschluesselung, Argon2id und TOTP-Zweitfaktor

Die Sicherheitsschicht, auf der alles Weitere aufsetzt.

Feldverschluesselung (AES-256-GCM):
- Umschlagformat bc1.<version>.<iv>.<tag>.<ct>. Die Schluesselversion
  erlaubt Rotation, ohne alte Daten unlesbar zu machen.
- Der Feldname wird als AAD mitauthentifiziert. Ein Geheimtext laesst
  sich damit nicht von diagnosen nach notizen umhaengen.
- Leere Werte werden zu NULL statt zu einem Umschlag - sonst verriete
  die Datenbank, dass ueberhaupt etwas eingetragen war.
- VERSCHLUESSELTE_FELDER ist der einzige Ort, an dem steht, was
  verschluesselt gehoert. sealen()/oeffnen() arbeiten darauf.

Argon2id nach OWASP-Empfehlung (19 MiB, t=2, p=1), mit hashVeraltet()
fuer spaetere Parametererhoehung ohne Migration.

Blindindex per HMAC-SHA256 mit eigenem Schluessel: erlaubt die Frage
"kenne ich diese IBAN schon?", ohne sie im Klartext zu halten.
Derselbe Mechanismus pseudonymisiert IP-Adressen fuers Audit-Log.

TOTP mit otpauth, QR-Code serverseitig als Data-URI - kein externer
QR-Dienst, kein CDN. Wiederherstellungscodes aus einem Alphabet ohne
0/O und 1/I, einzeln Argon2id-gehasht, Pruefung ohne fruehen Abbruch.

37 Tests decken Rundlauf, Manipulation, Feldbindung, Rotation,
Salzung und Normalisierung ab.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:42:13 +02:00
parent 6c1915ab9c
commit c49810d092
11 changed files with 10949 additions and 3 deletions
+227
View File
@@ -0,0 +1,227 @@
import 'server-only'
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'node:crypto'
import { env } from '@/lib/env'
/**
* Anwendungsseitige Feldverschluesselung mit AES-256-GCM.
*
* Warum zusaetzlich zur Festplattenverschluesselung? Weil ein Datenbank-Dump,
* ein Backup auf fremdem Speicher oder ein kompromittierter Datenbankzugang
* sonst Diagnosen, IBANs und Notizen im Klartext preisgibt. Der Schluessel
* liegt in der Umgebung, nicht in der Datenbank - beides zusammen wird
* gebraucht, um die Daten zu lesen.
*
* Umschlagformat (ASCII, punktgetrennt, base64url ohne Padding):
*
* bc1.<schluesselversion>.<iv>.<authtag>.<geheimtext>
*
* "bc1" = Betreuungs-Cockpit, Formatversion 1. Die Schluesselversion erlaubt
* Rotation: neue Werte werden mit ENC_KEY_AKTIV verschluesselt, alte bleiben
* mit ihrem Schluessel lesbar.
*
* Zusatzdaten (AAD): Der Feldname wird mitauthentifiziert. Dadurch laesst
* sich ein Geheimtext nicht von einem Feld in ein anderes kopieren -
* "diagnosen" bleibt "diagnosen".
*/
const PRAEFIX = 'bc1'
const IV_LAENGE = 12 // 96 Bit, der von NIST empfohlene Wert fuer GCM
const TAG_LAENGE = 16
function b64url(buf: Buffer): string {
return buf.toString('base64url')
}
function ausB64url(text: string): Buffer {
return Buffer.from(text, 'base64url')
}
/**
* Verschluesselt einen Klartext. Gibt null zurueck, wenn nichts zu
* verschluesseln ist - so bleiben leere Felder in der Datenbank NULL und
* verraten nicht einmal, dass etwas eingetragen war.
*/
export function verschluesseln(klartext: string | null | undefined, kontext = ''): string | null {
if (klartext === null || klartext === undefined || klartext === '') return null
const version = env.aktiveSchluesselversion
const schluessel = env.verschluesselungsschluessel.get(version)
if (!schluessel) {
throw new Error(`Verschluesselungsschluessel Version ${version} nicht verfuegbar.`)
}
const iv = randomBytes(IV_LAENGE)
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
cipher.setAAD(Buffer.from(`${PRAEFIX}:${version}:${kontext}`, 'utf8'))
const geheimtext = Buffer.concat([cipher.update(klartext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return [PRAEFIX, version, b64url(iv), b64url(tag), b64url(geheimtext)].join('.')
}
/**
* Entschluesselt einen Umschlag. Wirft, wenn der Geheimtext manipuliert
* wurde oder der Schluessel nicht passt - stilles Zurueckfallen auf den
* Rohwert waere hier gefaehrlich.
*/
export function entschluesseln(umschlag: string | null | undefined, kontext = ''): string | null {
if (umschlag === null || umschlag === undefined || umschlag === '') return null
const teile = umschlag.split('.')
if (teile.length !== 5 || teile[0] !== PRAEFIX) {
throw new Error(
'Ungueltiger Umschlag: erwartet wurde das Format bc1.<version>.<iv>.<tag>.<ct>.',
)
}
const version = Number.parseInt(teile[1]!, 10)
const schluessel = env.verschluesselungsschluessel.get(version)
if (!schluessel) {
throw new Error(
`Zum Entschluesseln fehlt ENC_KEY_V${version}. Der Schluessel darf beim ` +
`Rotieren nicht aus der Umgebung entfernt werden, solange noch Daten ` +
`mit ihm verschluesselt sind.`,
)
}
const iv = ausB64url(teile[2]!)
const tag = ausB64url(teile[3]!)
const geheimtext = ausB64url(teile[4]!)
const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
decipher.setAAD(Buffer.from(`${PRAEFIX}:${version}:${kontext}`, 'utf8'))
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(geheimtext), decipher.final()]).toString('utf8')
}
/** Entschluesselt, gibt aber bei Fehlern null zurueck statt zu werfen. */
export function entschluesselnWennMoeglich(
umschlag: string | null | undefined,
kontext = '',
): string | null {
try {
return entschluesseln(umschlag, kontext)
} catch {
return null
}
}
/** Erkennt, ob ein Wert bereits ein Umschlag ist (fuer Migrationen). */
export function istUmschlag(wert: unknown): wert is string {
return typeof wert === 'string' && wert.startsWith(`${PRAEFIX}.`) && wert.split('.').length === 5
}
/** Liest die Schluesselversion aus einem Umschlag - fuer die Rotation. */
export function schluesselversionVon(umschlag: string): number | null {
if (!istUmschlag(umschlag)) return null
return Number.parseInt(umschlag.split('.')[1]!, 10)
}
/**
* Verschluesselt einen bestehenden Umschlag mit dem aktuell aktiven
* Schluessel neu. Gibt null zurueck, wenn er bereits aktuell ist.
* Wird vom Rotationsskript benutzt.
*/
export function neuVerschluesseln(umschlag: string, kontext = ''): string | null {
if (schluesselversionVon(umschlag) === env.aktiveSchluesselversion) return null
const klartext = entschluesseln(umschlag, kontext)
return verschluesseln(klartext, kontext)
}
// -----------------------------------------------------------------------------
// Bequeme Helfer fuer ganze Datensaetze
// -----------------------------------------------------------------------------
/**
* Liste der verschluesselten Felder je Modell. Einziger Ort, an dem steht,
* was verschluesselt gehoert - Grundlage fuer sealen/oeffnen und fuer den
* Test, der prueft, dass nichts vergessen wurde.
*/
export const VERSCHLUESSELTE_FELDER = {
Betreute: [
'krankenversichertennummerVerschluesselt',
'diagnosenVerschluesselt',
'ermittelterWilleVerschluesselt',
'rentenversicherungsnummerVerschluesselt',
'steuerIdVerschluesselt',
'notizenVerschluesselt',
],
User: ['totpSecretVerschluesselt'],
Vorgang: ['beschreibungVerschluesselt'],
Genehmigung: ['notizenVerschluesselt'],
Dokument: ['notizenVerschluesselt'],
Konto: ['ibanVerschluesselt'],
Buchung: ['verwendungszweckVerschluesselt'],
Vermoegensposten: ['bewertungBegruendungVerschluesselt', 'notizenVerschluesselt'],
Immobilie: ['statusBegruendungVerschluesselt', 'notizenVerschluesselt'],
Vertrag: ['bewertungBegruendungVerschluesselt', 'notizenVerschluesselt'],
Kontakt: ['notizenVerschluesselt'],
Kontaktprotokoll: [
'inhaltVerschluesselt',
'wuenscheVerschluesselt',
'folgeaufgabenVerschluesselt',
],
Gesundheitsereignis: ['beschreibungVerschluesselt'],
Heimvertrag: ['notizenVerschluesselt'],
SozialhilfeAntrag: ['notizenVerschluesselt'],
Bescheid: ['notizenVerschluesselt'],
Bericht: ['entwurfVerschluesselt'],
AuditLog: ['aenderungVerschluesselt'],
} as const
export type VerschluesseltesModell = keyof typeof VERSCHLUESSELTE_FELDER
/**
* Nimmt ein Objekt mit Klartextfeldern (ohne Suffix) und liefert die
* verschluesselten Felder (mit Suffix). Beispiel:
*
* sealen('Betreute', { diagnosen: 'COPD GOLD III' })
* -> { diagnosenVerschluesselt: 'bc1.1....' }
*
* Felder, die im Eingabeobjekt fehlen, tauchen im Ergebnis nicht auf -
* ein Prisma-Update laesst sie damit unangetastet.
*/
export function sealen(
modell: VerschluesseltesModell,
klartextfelder: Record<string, string | null | undefined>,
): Record<string, string | null> {
const ergebnis: Record<string, string | null> = {}
for (const [name, wert] of Object.entries(klartextfelder)) {
if (wert === undefined) continue
const zielfeld = `${name}Verschluesselt`
if (!(VERSCHLUESSELTE_FELDER[modell] as readonly string[]).includes(zielfeld)) {
throw new Error(`${modell}.${zielfeld} ist nicht als verschluesseltes Feld eingetragen.`)
}
ergebnis[zielfeld] = verschluesseln(wert, zielfeld)
}
return ergebnis
}
/**
* Gegenstueck zu sealen: nimmt einen Datensatz aus der Datenbank und
* ergaenzt die entschluesselten Klartextfelder ohne Suffix. Die
* Umschlagfelder bleiben stehen, damit nichts versehentlich zurueck-
* geschrieben wird.
*/
export function oeffnen<T extends Record<string, unknown>>(
modell: VerschluesseltesModell,
datensatz: T,
): T & Record<string, string | null> {
const ergebnis = { ...datensatz } as T & Record<string, string | null>
for (const feld of VERSCHLUESSELTE_FELDER[modell] as readonly string[]) {
if (!(feld in datensatz)) continue
const klarname = feld.replace(/Verschluesselt$/, '')
ergebnis[klarname] = entschluesselnWennMoeglich(datensatz[feld] as string | null, feld)
}
return ergebnis
}
/** Konstantzeit-Vergleich zweier Zeichenketten. */
export function gleichSicher(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8')
const bufB = Buffer.from(b, 'utf8')
if (bufA.length !== bufB.length) return false
return timingSafeEqual(bufA, bufB)
}
+141
View File
@@ -0,0 +1,141 @@
import 'server-only'
import { hash, verify, Algorithm } from '@node-rs/argon2'
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { env } from '@/lib/env'
/**
* Passwort-Hashing mit Argon2id.
*
* Parameter orientieren sich an der OWASP-Empfehlung (Stand 2024):
* 19 MiB Speicher, 2 Durchlaeufe, Parallelitaet 1. Das ist auf einem
* kleinen Server noch bequem zu rechnen und fuer einen Angreifer teuer.
* Die Parameter stehen im Hash-String selbst, spaetere Erhoehungen
* lassen sich also ohne Migration einfuehren (siehe hashVeraltet).
*/
const ARGON2_OPTIONEN = {
algorithm: Algorithm.Argon2id,
memoryCost: 19_456, // KiB = 19 MiB
timeCost: 2,
parallelism: 1,
} as const
export async function passwortHashen(passwort: string): Promise<string> {
return hash(passwort, ARGON2_OPTIONEN)
}
/**
* Prueft ein Passwort. Wirft nie - ein kaputter Hash fuehrt zu "falsch",
* nicht zu einem Serverfehler mit Stacktrace am Anmeldeformular.
*/
export async function passwortPruefen(hashWert: string, passwort: string): Promise<boolean> {
try {
return await verify(hashWert, passwort, ARGON2_OPTIONEN)
} catch {
return false
}
}
/**
* Sagt, ob ein Hash mit schwaecheren Parametern erzeugt wurde und beim
* naechsten erfolgreichen Login neu gerechnet werden sollte.
*/
export function hashVeraltet(hashWert: string): boolean {
const m = hashWert.match(/\$m=(\d+),t=(\d+),p=(\d+)/)
if (!m) return true
const [, speicher, zeit] = m
return (
Number.parseInt(speicher!, 10) < ARGON2_OPTIONEN.memoryCost ||
Number.parseInt(zeit!, 10) < ARGON2_OPTIONEN.timeCost
)
}
// -----------------------------------------------------------------------------
// Blindindex
// -----------------------------------------------------------------------------
/**
* HMAC-SHA256 ueber einen normalisierten Wert. Erlaubt Gleichheitssuche
* auf verschluesselten Feldern ("Habe ich dieses Konto schon?"), ohne den
* Wert im Klartext zu speichern.
*
* Bewusste Einschraenkung: nur Gleichheit, kein "beginnt mit", kein
* Sortieren. Wer den Index sieht, kann bei kleinem Wertebereich raten -
* fuer IBANs und Versicherungsnummern ist der Raum gross genug.
*/
export function blindindex(wert: string | null | undefined): string | null {
if (!wert) return null
const normalisiert = wert.replace(/\s+/g, '').toUpperCase()
if (normalisiert === '') return null
return createHmac('sha256', env.indexSchluessel).update(normalisiert, 'utf8').digest('base64url')
}
/**
* Pseudonymisiert eine IP-Adresse fuer das Audit-Log. Wir brauchen sie nur,
* um "war das derselbe Anschluss?" zu beantworten - nicht, um sie zu
* speichern. Datensparsamkeit nach Art. 5 Abs. 1 lit. c DSGVO.
*/
export function ipHashen(ip: string | null | undefined): string | null {
if (!ip) return null
return createHmac('sha256', env.indexSchluessel).update(ip, 'utf8').digest('base64url').slice(0, 22)
}
// -----------------------------------------------------------------------------
// Wiederherstellungscodes
// -----------------------------------------------------------------------------
/** Verwechslungsarme Zeichen: kein 0/O, kein 1/I/l. */
const CODE_ZEICHEN = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
/**
* Erzeugt Wiederherstellungscodes im Format XXXX-XXXX-XXXX.
* Sie sind der einzige Weg zurueck, wenn das Handy mit der TOTP-App
* verloren geht - deshalb werden sie beim Einrichten einmal angezeigt
* und danach nur noch als Argon2id-Hash gespeichert.
*/
export function wiederherstellungscodesErzeugen(anzahl = 8): string[] {
const codes: string[] = []
for (let i = 0; i < anzahl; i++) {
const zeichen: string[] = []
const zufall = randomBytes(12)
for (let j = 0; j < 12; j++) {
zeichen.push(CODE_ZEICHEN[zufall[j]! % CODE_ZEICHEN.length]!)
}
codes.push(`${zeichen.slice(0, 4).join('')}-${zeichen.slice(4, 8).join('')}-${zeichen.slice(8, 12).join('')}`)
}
return codes
}
export function codeNormalisieren(code: string): string {
return code.replace(/[^A-Za-z0-9]/g, '').toUpperCase()
}
export async function wiederherstellungscodeHashen(code: string): Promise<string> {
return hash(codeNormalisieren(code), ARGON2_OPTIONEN)
}
/**
* Prueft einen eingegebenen Code gegen die Liste der gespeicherten Hashes.
* Gibt den Index des Treffers zurueck (damit der Aufrufer ihn verbrauchen
* kann) oder -1.
*
* Es wird bewusst nicht fruehzeitig abgebrochen: alle Kandidaten werden
* geprueft, damit die Laufzeit nicht verraet, der wievielte Code passte.
*/
export async function wiederherstellungscodeFinden(
hashes: string[],
code: string,
): Promise<number> {
const normalisiert = codeNormalisieren(code)
let treffer = -1
for (let i = 0; i < hashes.length; i++) {
const passt = await passwortPruefen(hashes[i]!, normalisiert)
if (passt && treffer === -1) treffer = i
}
return treffer
}
/** Konstantzeit-Vergleich zweier Puffer gleicher Laenge. */
export function pufferGleich(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
+95
View File
@@ -0,0 +1,95 @@
import 'server-only'
import { Secret, TOTP } from 'otpauth'
import QRCode from 'qrcode'
import { entschluesseln, verschluesseln } from '@/lib/crypto/feld'
/**
* Zweiter Faktor per TOTP (RFC 6238) - kompatibel mit Aegis, andOTP,
* Google Authenticator, 1Password, Bitwarden.
*
* Bewusst kein SMS und kein E-Mail-Code: beides ist schwaecher und wuerde
* Gesundheitsdaten hinter einen Mobilfunkanschluss haengen.
*
* Die Toleranz von einem Zeitfenster (window = 1) faengt Uhrenabweichung
* von bis zu 30 Sekunden ab. Mehr nicht - jedes zusaetzliche Fenster
* verlaengert die Zeit, in der ein abgefangener Code gilt.
*/
const AUSSTELLER = 'Betreuungs-Cockpit'
const ZIFFERN = 6
const PERIODE = 30
const TOLERANZ_FENSTER = 1
function totpBauen(secret: string, kennung: string): TOTP {
return new TOTP({
issuer: AUSSTELLER,
label: kennung,
algorithm: 'SHA1', // von allen gaengigen Apps unterstuetzt
digits: ZIFFERN,
period: PERIODE,
secret: Secret.fromBase32(secret),
})
}
/** Erzeugt ein neues, noch nicht aktiviertes TOTP-Geheimnis (Base32). */
export function totpSecretErzeugen(): string {
return new Secret({ size: 20 }).base32
}
/** otpauth://-URI zum Einscannen. */
export function totpUri(secret: string, kennung: string): string {
return totpBauen(secret, kennung).toString()
}
/**
* QR-Code als Data-URI. Wird serverseitig erzeugt und inline eingebettet -
* es darf kein externer QR-Dienst und kein CDN im Spiel sein.
*/
export async function totpQrCode(secret: string, kennung: string): Promise<string> {
return QRCode.toDataURL(totpUri(secret, kennung), {
errorCorrectionLevel: 'M',
margin: 1,
width: 240,
color: { dark: '#000000ff', light: '#ffffffff' },
})
}
/**
* Prueft einen eingegebenen Sechsstelligen gegen das Geheimnis.
* Gibt zusaetzlich das getroffene Zeitfenster zurueck - der Aufrufer
* speichert es, um eine Wiederverwendung desselben Codes zu verhindern.
*/
export function totpPruefen(
secret: string,
kennung: string,
eingabe: string,
): { gueltig: boolean; delta: number | null } {
const bereinigt = eingabe.replace(/\D/g, '')
if (bereinigt.length !== ZIFFERN) return { gueltig: false, delta: null }
const delta = totpBauen(secret, kennung).validate({
token: bereinigt,
window: TOLERANZ_FENSTER,
})
return { gueltig: delta !== null, delta }
}
/** Verschluesselt das Geheimnis fuer die Ablage in der Datenbank. */
export function totpSecretVerschluesseln(secret: string): string {
const umschlag = verschluesseln(secret, 'totpSecretVerschluesselt')
if (!umschlag) throw new Error('TOTP-Geheimnis darf nicht leer sein.')
return umschlag
}
/** Holt das Geheimnis wieder heraus. */
export function totpSecretEntschluesseln(umschlag: string): string {
const secret = entschluesseln(umschlag, 'totpSecretVerschluesselt')
if (!secret) throw new Error('TOTP-Geheimnis konnte nicht entschluesselt werden.')
return secret
}
/** Aktueller Code - nur fuer Tests und die Ersteinrichtungs-Kontrolle. */
export function totpAktuellerCode(secret: string, kennung: string): string {
return totpBauen(secret, kennung).generate()
}
+177
View File
@@ -0,0 +1,177 @@
import 'server-only'
/**
* Zugriff auf die Umgebungsvariablen - mit Pruefung, aber bewusst faul.
*
* Warum faul? `next build` importiert Servermodule, ohne dass eine .env mit
* echten Schluesseln vorliegen muss (z.B. im Docker-Build). Wuerden wir beim
* Import validieren, schluege jeder Build ohne Produktivschluessel fehl.
* Stattdessen prueft jeder Zugriff beim ersten Mal und merkt sich das
* Ergebnis. Fehlt etwas, kommt eine Meldung, die sagt, was zu tun ist.
*/
function pflicht(name: string): string {
const wert = process.env[name]
if (!wert || wert.trim() === '') {
throw new Error(
`Umgebungsvariable ${name} fehlt. Lege sie in der .env an - ` +
`mit "npm run schluessel" erzeugst du passende Werte.`,
)
}
return wert
}
function zahl(name: string, standard: number): number {
const wert = process.env[name]
if (!wert) return standard
const n = Number.parseInt(wert, 10)
if (Number.isNaN(n)) {
throw new Error(`Umgebungsvariable ${name} muss eine Zahl sein, ist aber "${wert}".`)
}
return n
}
function schluessel32(name: string): Buffer {
const roh = pflicht(name)
const buf = Buffer.from(roh, 'base64')
if (buf.length !== 32) {
throw new Error(
`Umgebungsvariable ${name} muss 32 Bytes base64-kodiert sein ` +
`(ist ${buf.length} Bytes). Erzeuge sie mit "npm run schluessel".`,
)
}
return buf
}
/** Merkt sich das Ergebnis, damit nicht bei jedem Zugriff neu geparst wird. */
function einmal<T>(fn: () => T): () => T {
let wert: T
let geladen = false
return () => {
if (!geladen) {
wert = fn()
geladen = true
}
return wert
}
}
const holeVerschluesselungsschluessel = einmal((): Map<number, Buffer> => {
const schluessel = new Map<number, Buffer>()
// ENC_KEY_V1, ENC_KEY_V2, ... - alte Versionen bleiben zum Entschluesseln
// stehen, verschluesselt wird immer mit der aktiven Version.
for (const [name] of Object.entries(process.env)) {
const treffer = name.match(/^ENC_KEY_V(\d+)$/)
if (treffer) {
schluessel.set(Number.parseInt(treffer[1]!, 10), schluessel32(name))
}
}
if (schluessel.size === 0) {
throw new Error(
'Kein Verschluesselungsschluessel gefunden. Es muss mindestens ENC_KEY_V1 ' +
'gesetzt sein. Erzeuge ihn mit "npm run schluessel".',
)
}
return schluessel
})
const holeAktiveVersion = einmal((): number => {
const version = zahl('ENC_KEY_AKTIV', 1)
if (!holeVerschluesselungsschluessel().has(version)) {
throw new Error(
`ENC_KEY_AKTIV=${version}, aber ENC_KEY_V${version} ist nicht gesetzt.`,
)
}
return version
})
const holeIndexSchluessel = einmal(() => schluessel32('INDEX_KEY'))
export const env = {
/** Alle bekannten Verschluesselungsschluessel nach Version. */
get verschluesselungsschluessel() {
return holeVerschluesselungsschluessel()
},
/** Version, mit der neu verschluesselt wird. */
get aktiveSchluesselversion() {
return holeAktiveVersion()
},
/** Separater HMAC-Schluessel fuer Blindindizes. */
get indexSchluessel() {
return holeIndexSchluessel()
},
get datenbankUrl() {
return pflicht('DATABASE_URL')
},
// --- Sitzung und Anmeldung ------------------------------------------------
get sitzungMaxAlterSekunden() {
return zahl('SESSION_MAX_AGE', 30 * 60)
},
get reauthMaxAlterSekunden() {
return zahl('REAUTH_MAX_AGE', 5 * 60)
},
get loginMaxVersuche() {
return zahl('LOGIN_MAX_VERSUCHE', 5)
},
get loginFensterSekunden() {
return zahl('LOGIN_FENSTER_SEKUNDEN', 15 * 60)
},
get loginSperreSekunden() {
return zahl('LOGIN_SPERRE_SEKUNDEN', 15 * 60)
},
// --- Objektspeicher -------------------------------------------------------
get speicherTreiber(): 'datei' | 's3' {
const wert = process.env.SPEICHER_TREIBER ?? (process.env.S3_ACCESS_KEY_ID ? 's3' : 'datei')
return wert === 's3' ? 's3' : 'datei'
},
get dateiSpeicherPfad() {
return process.env.DATEI_SPEICHER_PFAD ?? './daten/dokumente'
},
get s3() {
return {
endpoint: pflicht('S3_ENDPOINT'),
region: process.env.S3_REGION ?? 'eu-central',
bucket: pflicht('S3_BUCKET'),
accessKeyId: pflicht('S3_ACCESS_KEY_ID'),
secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'),
}
},
// --- OCR ------------------------------------------------------------------
get tesseractBefehl() {
return process.env.TESSERACT_BEFEHL ?? 'tesseract'
},
get ocrAktiv() {
return process.env.OCR_AKTIV !== 'false'
},
// --- Uploads --------------------------------------------------------------
get uploadMaxBytes() {
return zahl('UPLOAD_MAX_BYTES', 25 * 1024 * 1024)
},
// --- E-Mail ---------------------------------------------------------------
get smtpKonfiguriert() {
return Boolean(process.env.SMTP_HOST)
},
get smtp() {
return {
host: pflicht('SMTP_HOST'),
port: zahl('SMTP_PORT', 587),
user: process.env.SMTP_USER,
passwort: process.env.SMTP_PASSWORD,
absender: process.env.SMTP_FROM ?? 'Betreuungs-Cockpit <noreply@localhost>',
}
},
get basisUrl() {
return (process.env.AUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '')
},
get istProduktion() {
return process.env.NODE_ENV === 'production'
},
} as const