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
+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()
}