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 /** Laenge eines TOTP-Fensters in Sekunden - fuer den Wiederverwendungsschutz. */ export const TOTP_PERIODE = PERIODE 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 { return QRCode.toDataURL(totpUri(secret, kennung), { errorCorrectionLevel: 'M', margin: 1, width: 240, color: { dark: '#000000ff', light: '#ffffffff' }, }) } /** * Der absolute Zeitschritt, auf den ein Code passt. * * validate() gibt nur die Abweichung zum aktuellen Fenster zurueck (delta). * Zum Festhalten braucht es eine Zahl, die nicht davon abhaengt, wann * geprueft wurde - sonst waere ein spaeter geprueftes Fenster kleiner als * ein frueher geprueftes. */ export function totpSchritt(delta: number, jetztMs = Date.now()): number { return Math.floor(jetztMs / 1000 / PERIODE) + delta } /** * Prueft einen eingegebenen Sechsstelligen gegen das Geheimnis. * * Gibt zusaetzlich das getroffene Zeitfenster zurueck. Wer damit anmeldet, * muss es ueber zweitenFaktorEinloesen() festhalten - sonst gilt derselbe * Code bis zu 90 Sekunden lang ein zweites Mal. */ 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() }