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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user