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 } /** * Ein Ja/Nein-Schalter. Bewusst nur "true"/"1"/"ja" als Ja - alles andere, * auch ein Tippfehler, gilt als Nein. Bei einem Sicherheitsschalter ist das * die richtige Richtung: Wer sich verschreibt, bekommt die strengere * Einstellung, nicht die laxere. */ function schalter(name: string, standard = false): boolean { const wert = process.env[name]?.trim().toLowerCase() if (wert === undefined || wert === '') return standard return wert === 'true' || wert === '1' || wert === 'ja' } 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(fn: () => T): () => T { let wert: T let geladen = false return () => { if (!geladen) { wert = fn() geladen = true } return wert } } const holeVerschluesselungsschluessel = einmal((): Map => { const schluessel = new Map() // ENC_KEY_V1, ENC_KEY_V2, ... - alte Versionen bleiben zum Entschluesseln // stehen, verschluesselt wird immer mit der aktiven Version. for (const [name, wert] of Object.entries(process.env)) { const treffer = name.match(/^ENC_KEY_V(\d+)$/) if (!treffer) continue // Eine LEERE Variable ist kein Schluessel. // // docker compose reicht ENC_KEY_V2 immer durch - mit leerem Wert, wenn // in der .env keiner steht. Wuerde hier allein der Name zaehlen, waere // jede Installation ohne zweiten Schluessel kaputt, und zwar mit der // irrefuehrenden Meldung "ENC_KEY_V2 fehlt" - obwohl niemand einen // zweiten Schluessel wollte. if (!wert || wert.trim() === '') continue 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)) { const vorhanden = [...holeVerschluesselungsschluessel().keys()].sort().join(', ') throw new Error( `ENC_KEY_AKTIV=${version}, aber ENC_KEY_V${version} ist nicht gesetzt oder leer. ` + `Vorhanden sind die Versionen: ${vorhanden}. Beim Rotieren zuerst den ` + `neuen Schluessel eintragen, danach ENC_KEY_AKTIV umstellen.`, ) } 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'), /** * Serverseitige Verschluesselung - nur, wenn ausdruecklich gewuenscht. * * Frueher stand im Treiber fest ServerSideEncryption: 'AES256'. Das * mitgelieferte MinIO laeuft ohne KMS und lehnt das mit * "NotImplemented" ab: Es liess sich kein einziges Dokument ablegen, * und wer der INSTALLATION.md folgte, landete zwangslaeufig dort. * * Gebraucht wird sie ohnehin nicht mehr. Seit v1.11.0 verschluesselt * die Anwendung jede Datei selbst, bevor sie den Server verlaesst * (src/lib/speicher/umschlag.ts). Serverseitige Verschluesselung * schuetzt vor nichts, was der eigene Umschlag nicht schon abdeckt - * sie ist hoechstens eine zweite Lage fuer Anbieter, die sie * anbieten. * * Leer bedeutet aus. Wer sie will, setzt S3_SSE=AES256 oder den Wert, * den sein Anbieter erwartet. */ serverseitigeVerschluesselung: process.env.S3_SSE?.trim() || null, } }, // --- 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 ', } }, get basisUrl() { return (process.env.AUTH_URL ?? 'http://localhost:3000').replace(/\/$/, '') }, /** * Herkunft und Bereichskennung fuer Passkeys (WebAuthn). * * Beides wird aus AUTH_URL abgeleitet und nicht getrennt konfiguriert - * eine zweite Quelle waere eine zweite Gelegenheit, sie auseinanderlaufen * zu lassen. * * Die "rpId" ist der nackte Hostname ohne Schema und ohne Port. An sie * bindet der Authenticator den Schluessel: Ein Passkey fuer * cockpit.example.de funktioniert auf keiner anderen Adresse - genau das * macht ihn gegen Phishing immun. Umgekehrt heisst das: Wer die Domain * wechselt, muss alle Passkeys neu einrichten. * * Der Browser gibt WebAuthn ausserdem nur in einem sicheren Kontext frei - * also unter https oder auf localhost. Hinter einem Proxy, der auf http * weiterreicht, muss AUTH_URL trotzdem die oeffentliche https-Adresse * nennen; verglichen wird gegen das, was im Browser in der Adresszeile * stand. */ get passkey() { const url = new URL(process.env.AUTH_URL ?? 'http://localhost:3000') return { rpId: url.hostname, rpName: 'Betreuungs-Cockpit', herkunft: url.origin, sicherAufrufbar: url.protocol === 'https:' || url.hostname === 'localhost', } }, /** * Darf der Host-Kopf der Anfrage die oeffentliche Adresse bestimmen? * * Standard: nur dann, wenn AUTH_URL nicht gesetzt ist. Ist die Adresse * konfiguriert - der dokumentierte Betrieb setzt sie, Passkeys brauchen * sie ohnehin -, gibt es keinen Grund, sie aus einer Kopfzeile von aussen * abzuleiten. Vorher stand in auth.ts fest "true", und ein * "X-Forwarded-Host: fremde.example" haette in erzeugte Adressen * durchgeschlagen. * * AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung * nicht stillschweigend uebergangen wird. */ get vertraueHost() { return schalter('AUTH_TRUST_HOST', !process.env.AUTH_URL) }, /** * Geheimnis fuer die Ersteinrichtung. * * Zwischen dem ersten Start und dem Anlegen des Zugangs steht die Seite * offen: Wer sie in diesem Fenster findet, legt sich selbst einen * Administratorzugang an. Das Fenster ist kurz, aber es ist da - und bei * einer Anwendung mit Gesundheits- und Finanzdaten ist "kurz" kein * Argument. * * Ohne diesen Wert ist die Ersteinrichtung gesperrt. Das ist Absicht: * Eine Installation, bei der das Feld vergessen wurde, soll nicht * einrichtbar sein, sondern eine Meldung zeigen. */ get ersteinrichtungGeheimnis() { return process.env.ERSTEINRICHTUNG_GEHEIMNIS?.trim() ?? '' }, /** * Steht ein Reverse Proxy davor, dem die weitergereichten Koepfe zu * glauben sind? * * Standard ist nein. Das ist die unbequeme, aber richtige Voreinstellung: * Ohne Proxy ist "x-forwarded-for" frei erfunden, und eine Sperre, die * darauf zaehlt, laesst sich mit einer Kopfzeile aushebeln. Wer einen * Proxy davorstellt - der dokumentierte Betrieb tut das -, sagt es hier * ausdruecklich. Siehe src/lib/auth/anschluss.ts. */ get vertraueProxy() { return schalter('VERTRAUE_PROXY', false) }, /** * Wie viele Zwischenknoten vertraut werden. Bei einem Proxy: 1. * * Die Zahl sagt, wie weit von hinten in der Kette der Aufrufer steht. * Zu hoch gesetzt liest man wieder fremdbestimmte Eintraege, deshalb * bleibt es bei eins, solange nichts anderes dasteht. */ get vertraueProxyStufen() { return Math.max(1, zahl('VERTRAUE_PROXY_STUFEN', 1)) }, get istProduktion() { return process.env.NODE_ENV === 'production' }, } as const