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:
Generated
+9946
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwindcss-animate": "1.0.7",
|
||||
"zod": "3.24.1"
|
||||
@@ -65,5 +66,14 @@
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "5.7.3",
|
||||
"vitest": "3.0.5"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@prisma/client@6.3.1": true,
|
||||
"@prisma/engines@6.3.1": true,
|
||||
"esbuild@0.23.1": true,
|
||||
"esbuild@0.25.12": true,
|
||||
"prisma@6.3.1": true,
|
||||
"sharp@0.33.5": true,
|
||||
"unrs-resolver@1.12.2": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
entschluesseln,
|
||||
entschluesselnWennMoeglich,
|
||||
gleichSicher,
|
||||
istUmschlag,
|
||||
neuVerschluesseln,
|
||||
oeffnen,
|
||||
schluesselversionVon,
|
||||
sealen,
|
||||
VERSCHLUESSELTE_FELDER,
|
||||
verschluesseln,
|
||||
} from '@/lib/crypto/feld'
|
||||
|
||||
describe('Feldverschluesselung', () => {
|
||||
it('verschluesselt und entschluesselt verlustfrei', () => {
|
||||
const klartext = 'COPD GOLD III, Langzeitsauerstofftherapie seit 03/2025'
|
||||
const umschlag = verschluesseln(klartext, 'diagnosenVerschluesselt')
|
||||
expect(umschlag).not.toBeNull()
|
||||
expect(umschlag).not.toContain('COPD')
|
||||
expect(entschluesseln(umschlag, 'diagnosenVerschluesselt')).toBe(klartext)
|
||||
})
|
||||
|
||||
it('haelt Umlaute und Sonderzeichen aus', () => {
|
||||
const klartext = 'Straße 3½, Grüße – „Anführungszeichen“, 100 €'
|
||||
const umschlag = verschluesseln(klartext, 'notizenVerschluesselt')
|
||||
expect(entschluesseln(umschlag, 'notizenVerschluesselt')).toBe(klartext)
|
||||
})
|
||||
|
||||
it('erzeugt bei gleichem Klartext unterschiedliche Geheimtexte', () => {
|
||||
// Sonst liesse sich aus der Datenbank ablesen, welche Datensaetze
|
||||
// denselben Wert tragen.
|
||||
const a = verschluesseln('DE02120300000000202051', 'ibanVerschluesselt')
|
||||
const b = verschluesseln('DE02120300000000202051', 'ibanVerschluesselt')
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('behandelt leere Werte als NULL', () => {
|
||||
expect(verschluesseln(null)).toBeNull()
|
||||
expect(verschluesseln(undefined)).toBeNull()
|
||||
expect(verschluesseln('')).toBeNull()
|
||||
expect(entschluesseln(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('erkennt manipulierte Geheimtexte', () => {
|
||||
const umschlag = verschluesseln('Rueckkaufswert 12.480,00 EUR', 'notizenVerschluesselt')!
|
||||
const teile = umschlag.split('.')
|
||||
// Ein Bit im Geheimtext kippen.
|
||||
const roh = Buffer.from(teile[4]!, 'base64url')
|
||||
roh[0] = roh[0]! ^ 0x01
|
||||
teile[4] = roh.toString('base64url')
|
||||
expect(() => entschluesseln(teile.join('.'), 'notizenVerschluesselt')).toThrow()
|
||||
})
|
||||
|
||||
it('verhindert das Umhaengen eines Geheimtexts in ein anderes Feld', () => {
|
||||
// Der Feldname wird als AAD mitauthentifiziert.
|
||||
const umschlag = verschluesseln('geheim', 'diagnosenVerschluesselt')
|
||||
expect(() => entschluesseln(umschlag, 'notizenVerschluesselt')).toThrow()
|
||||
})
|
||||
|
||||
it('weist fremde Formate ab', () => {
|
||||
expect(() => entschluesseln('einfach nur text')).toThrow()
|
||||
expect(() => entschluesseln('bc1.1.zu.wenig')).toThrow()
|
||||
expect(entschluesselnWennMoeglich('einfach nur text')).toBeNull()
|
||||
})
|
||||
|
||||
it('erkennt Umschlaege und ihre Schluesselversion', () => {
|
||||
const umschlag = verschluesseln('x', 'notizenVerschluesselt')!
|
||||
expect(istUmschlag(umschlag)).toBe(true)
|
||||
expect(istUmschlag('nein')).toBe(false)
|
||||
expect(schluesselversionVon(umschlag)).toBe(1)
|
||||
})
|
||||
|
||||
it('rotiert Schluessel, ohne alte Daten unlesbar zu machen', () => {
|
||||
const klartext = 'DE02120300000000202051'
|
||||
const alt = verschluesseln(klartext, 'ibanVerschluesselt')!
|
||||
expect(schluesselversionVon(alt)).toBe(1)
|
||||
|
||||
process.env.ENC_KEY_AKTIV = '2'
|
||||
// env cached die aktive Version bereits - deshalb prueft dieser Test
|
||||
// nur, dass ein Umschlag der Version 1 weiterhin lesbar bleibt.
|
||||
process.env.ENC_KEY_AKTIV = '1'
|
||||
|
||||
expect(entschluesseln(alt, 'ibanVerschluesselt')).toBe(klartext)
|
||||
// Gleiche Version -> kein Neuverschluesseln noetig.
|
||||
expect(neuVerschluesseln(alt, 'ibanVerschluesselt')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sealen und oeffnen', () => {
|
||||
it('setzt die Suffix-Felder aus Klartextfeldern', () => {
|
||||
const daten = sealen('Betreute', {
|
||||
diagnosen: 'COPD GOLD III',
|
||||
notizen: 'Bevorzugt Besuche am Nachmittag',
|
||||
})
|
||||
expect(Object.keys(daten).sort()).toEqual([
|
||||
'diagnosenVerschluesselt',
|
||||
'notizenVerschluesselt',
|
||||
])
|
||||
expect(daten.diagnosenVerschluesselt).toMatch(/^bc1\./)
|
||||
})
|
||||
|
||||
it('laesst undefinierte Felder weg, damit Updates sie nicht loeschen', () => {
|
||||
const daten = sealen('Betreute', { diagnosen: 'COPD', notizen: undefined })
|
||||
expect('notizenVerschluesselt' in daten).toBe(false)
|
||||
})
|
||||
|
||||
it('setzt ein leeres Feld auf NULL', () => {
|
||||
const daten = sealen('Betreute', { notizen: '' })
|
||||
expect(daten.notizenVerschluesselt).toBeNull()
|
||||
})
|
||||
|
||||
it('weist unbekannte Felder ab', () => {
|
||||
expect(() => sealen('Betreute', { gibtEsNicht: 'x' })).toThrow(/nicht als verschluesseltes/)
|
||||
})
|
||||
|
||||
it('oeffnet einen Datensatz und ergaenzt die Klartextfelder', () => {
|
||||
const datenbankzeile = {
|
||||
id: 'abc',
|
||||
vorname: 'Erika',
|
||||
...sealen('Betreute', { diagnosen: 'COPD GOLD III', notizen: null }),
|
||||
}
|
||||
const offen = oeffnen('Betreute', datenbankzeile)
|
||||
expect(offen.vorname).toBe('Erika')
|
||||
expect(offen.diagnosen).toBe('COPD GOLD III')
|
||||
expect(offen.notizen).toBeNull()
|
||||
// Der Umschlag bleibt stehen.
|
||||
expect(offen.diagnosenVerschluesselt).toMatch(/^bc1\./)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Feldkatalog', () => {
|
||||
it('haelt fuer jedes Modell nur Felder mit dem richtigen Suffix', () => {
|
||||
for (const [modell, felder] of Object.entries(VERSCHLUESSELTE_FELDER)) {
|
||||
for (const feld of felder) {
|
||||
expect(feld, `${modell}.${feld}`).toMatch(/Verschluesselt$/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('deckt die besonderen Datenkategorien ab', () => {
|
||||
// Gesundheit, Finanzen, Freitext - das ist die Zusage aus der README.
|
||||
expect(VERSCHLUESSELTE_FELDER.Betreute).toContain('diagnosenVerschluesselt')
|
||||
expect(VERSCHLUESSELTE_FELDER.Konto).toContain('ibanVerschluesselt')
|
||||
expect(VERSCHLUESSELTE_FELDER.Vertrag).toContain('notizenVerschluesselt')
|
||||
expect(VERSCHLUESSELTE_FELDER.Gesundheitsereignis).toContain('beschreibungVerschluesselt')
|
||||
expect(VERSCHLUESSELTE_FELDER.Kontaktprotokoll).toContain('inhaltVerschluesselt')
|
||||
expect(VERSCHLUESSELTE_FELDER.AuditLog).toContain('aenderungVerschluesselt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gleichSicher', () => {
|
||||
it('vergleicht korrekt', () => {
|
||||
expect(gleichSicher('abc', 'abc')).toBe(true)
|
||||
expect(gleichSicher('abc', 'abd')).toBe(false)
|
||||
expect(gleichSicher('abc', 'abcd')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
// Platzhalter fuer das Paket "server-only", das unter Vitest nicht laufen
|
||||
// kann (es wirft absichtlich, wenn es ausserhalb einer Server-Umgebung
|
||||
// importiert wird). Siehe Alias in vitest.config.ts.
|
||||
export {}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Testschluessel. Bewusst fest verdrahtet und offensichtlich unecht -
|
||||
* so kann niemand auf die Idee kommen, sie in einer .env zu verwenden.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
process.env.ENC_KEY_V1 = Buffer.alloc(32, 1).toString('base64')
|
||||
process.env.ENC_KEY_V2 = Buffer.alloc(32, 2).toString('base64')
|
||||
process.env.ENC_KEY_AKTIV = '1'
|
||||
process.env.INDEX_KEY = Buffer.alloc(32, 3).toString('base64')
|
||||
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
|
||||
process.env.AUTH_SECRET = randomBytes(32).toString('base64')
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
totpAktuellerCode,
|
||||
totpPruefen,
|
||||
totpQrCode,
|
||||
totpSecretEntschluesseln,
|
||||
totpSecretErzeugen,
|
||||
totpSecretVerschluesseln,
|
||||
totpUri,
|
||||
} from '@/lib/crypto/totp'
|
||||
import {
|
||||
blindindex,
|
||||
codeNormalisieren,
|
||||
hashVeraltet,
|
||||
ipHashen,
|
||||
passwortHashen,
|
||||
passwortPruefen,
|
||||
wiederherstellungscodeFinden,
|
||||
wiederherstellungscodeHashen,
|
||||
wiederherstellungscodesErzeugen,
|
||||
} from '@/lib/crypto/hash'
|
||||
|
||||
describe('TOTP', () => {
|
||||
it('erzeugt ein Base32-Geheimnis brauchbarer Laenge', () => {
|
||||
const secret = totpSecretErzeugen()
|
||||
expect(secret).toMatch(/^[A-Z2-7]+$/)
|
||||
expect(secret.length).toBeGreaterThanOrEqual(32)
|
||||
})
|
||||
|
||||
it('akzeptiert den aktuellen Code und lehnt einen falschen ab', () => {
|
||||
const secret = totpSecretErzeugen()
|
||||
const code = totpAktuellerCode(secret, 'fw@wildeflorian.de')
|
||||
|
||||
expect(totpPruefen(secret, 'fw@wildeflorian.de', code).gueltig).toBe(true)
|
||||
expect(totpPruefen(secret, 'fw@wildeflorian.de', '000000').gueltig).toBe(false)
|
||||
})
|
||||
|
||||
it('toleriert Leerzeichen in der Eingabe, aber keine falsche Laenge', () => {
|
||||
const secret = totpSecretErzeugen()
|
||||
const code = totpAktuellerCode(secret, 'test')
|
||||
const mitLuecke = `${code.slice(0, 3)} ${code.slice(3)}`
|
||||
|
||||
expect(totpPruefen(secret, 'test', mitLuecke).gueltig).toBe(true)
|
||||
expect(totpPruefen(secret, 'test', '12345').gueltig).toBe(false)
|
||||
expect(totpPruefen(secret, 'test', '1234567').gueltig).toBe(false)
|
||||
})
|
||||
|
||||
it('bindet den Code an das Geheimnis', () => {
|
||||
const a = totpSecretErzeugen()
|
||||
const b = totpSecretErzeugen()
|
||||
const code = totpAktuellerCode(a, 'test')
|
||||
expect(totpPruefen(b, 'test', code).gueltig).toBe(false)
|
||||
})
|
||||
|
||||
it('baut eine otpauth-URI mit Aussteller und Kennung', () => {
|
||||
const secret = totpSecretErzeugen()
|
||||
const uri = totpUri(secret, 'fw@wildeflorian.de')
|
||||
expect(uri).toContain('otpauth://totp/')
|
||||
expect(uri).toContain('Betreuungs-Cockpit')
|
||||
expect(uri).toContain('fw%40wildeflorian.de')
|
||||
expect(uri).toContain('digits=6')
|
||||
expect(uri).toContain('period=30')
|
||||
})
|
||||
|
||||
it('liefert den QR-Code als eingebettetes Bild, nicht als Fremd-URL', async () => {
|
||||
const secret = totpSecretErzeugen()
|
||||
const bild = await totpQrCode(secret, 'fw@wildeflorian.de')
|
||||
expect(bild.startsWith('data:image/png;base64,')).toBe(true)
|
||||
expect(bild).not.toContain('http')
|
||||
})
|
||||
|
||||
it('speichert das Geheimnis nur verschluesselt', () => {
|
||||
const secret = totpSecretErzeugen()
|
||||
const umschlag = totpSecretVerschluesseln(secret)
|
||||
expect(umschlag).not.toContain(secret)
|
||||
expect(totpSecretEntschluesseln(umschlag)).toBe(secret)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Passwort-Hashing', () => {
|
||||
it('erzeugt einen Argon2id-Hash', async () => {
|
||||
const hash = await passwortHashen('ein-langes-passwort-2026')
|
||||
expect(hash.startsWith('$argon2id$')).toBe(true)
|
||||
expect(hash).not.toContain('ein-langes-passwort-2026')
|
||||
})
|
||||
|
||||
it('prueft richtig und falsch', async () => {
|
||||
const hash = await passwortHashen('ein-langes-passwort-2026')
|
||||
expect(await passwortPruefen(hash, 'ein-langes-passwort-2026')).toBe(true)
|
||||
expect(await passwortPruefen(hash, 'ein-langes-passwort-2025')).toBe(false)
|
||||
})
|
||||
|
||||
it('salzt - gleiches Passwort ergibt verschiedene Hashes', async () => {
|
||||
const a = await passwortHashen('gleiches-passwort')
|
||||
const b = await passwortHashen('gleiches-passwort')
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('faellt bei kaputtem Hash auf falsch zurueck, statt zu werfen', async () => {
|
||||
expect(await passwortPruefen('kein-hash', 'egal')).toBe(false)
|
||||
expect(await passwortPruefen('', 'egal')).toBe(false)
|
||||
})
|
||||
|
||||
it('erkennt zu schwach gerechnete Hashes', async () => {
|
||||
const aktuell = await passwortHashen('x')
|
||||
expect(hashVeraltet(aktuell)).toBe(false)
|
||||
expect(hashVeraltet('$argon2id$v=19$m=4096,t=1,p=1$abc$def')).toBe(true)
|
||||
expect(hashVeraltet('unsinn')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Blindindex', () => {
|
||||
it('ist stabil und normalisiert Schreibweisen', () => {
|
||||
const a = blindindex('DE02 1203 0000 0000 2020 51')
|
||||
const b = blindindex('de02120300000000202051')
|
||||
expect(a).toBe(b)
|
||||
expect(a).not.toBeNull()
|
||||
})
|
||||
|
||||
it('gibt den Wert nicht preis', () => {
|
||||
const index = blindindex('DE02120300000000202051')!
|
||||
expect(index).not.toContain('DE02')
|
||||
expect(index.length).toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
it('unterscheidet verschiedene Werte', () => {
|
||||
expect(blindindex('DE02120300000000202051')).not.toBe(blindindex('DE02120300000000202052'))
|
||||
})
|
||||
|
||||
it('behandelt Leerwerte als null', () => {
|
||||
expect(blindindex(null)).toBeNull()
|
||||
expect(blindindex('')).toBeNull()
|
||||
expect(blindindex(' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('pseudonymisiert IP-Adressen kurz und stabil', () => {
|
||||
const a = ipHashen('192.0.2.44')
|
||||
expect(a).toBe(ipHashen('192.0.2.44'))
|
||||
expect(a).not.toBe(ipHashen('192.0.2.45'))
|
||||
expect(a!.length).toBe(22)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Wiederherstellungscodes', () => {
|
||||
it('erzeugt lesbare Codes ohne verwechselbare Zeichen', () => {
|
||||
const codes = wiederherstellungscodesErzeugen(8)
|
||||
expect(codes).toHaveLength(8)
|
||||
for (const code of codes) {
|
||||
expect(code).toMatch(/^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/)
|
||||
expect(code).not.toMatch(/[01OI]/) // 0/O und 1/I sind verwechselbar, L in Grossschrift nicht
|
||||
}
|
||||
expect(new Set(codes).size).toBe(8)
|
||||
})
|
||||
|
||||
it('normalisiert die Eingabe', () => {
|
||||
expect(codeNormalisieren('abcd-efgh-jkmn')).toBe('ABCDEFGHJKMN')
|
||||
expect(codeNormalisieren('ABCD EFGH JKMN')).toBe('ABCDEFGHJKMN')
|
||||
})
|
||||
|
||||
it('findet einen gueltigen Code und weist einen falschen ab', async () => {
|
||||
const codes = wiederherstellungscodesErzeugen(3)
|
||||
const hashes = await Promise.all(codes.map(wiederherstellungscodeHashen))
|
||||
|
||||
expect(await wiederherstellungscodeFinden(hashes, codes[1]!)).toBe(1)
|
||||
// Auch klein geschrieben und ohne Bindestriche.
|
||||
expect(await wiederherstellungscodeFinden(hashes, codes[2]!.replace(/-/g, '').toLowerCase())).toBe(2)
|
||||
expect(await wiederherstellungscodeFinden(hashes, 'ZZZZ-ZZZZ-ZZZZ')).toBe(-1)
|
||||
expect(await wiederherstellungscodeFinden([], codes[0]!)).toBe(-1)
|
||||
})
|
||||
})
|
||||
+9
-3
@@ -5,10 +5,16 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
setupFiles: ['./tests/setup/umgebung.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
alias: [
|
||||
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
// "server-only" wirft absichtlich beim Import ausserhalb des Servers.
|
||||
{
|
||||
find: /^server-only$/,
|
||||
replacement: fileURLToPath(new URL('./tests/setup/leer.ts', import.meta.url)),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user