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:
+177
@@ -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
|
||||
Reference in New Issue
Block a user