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
+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