Files
betreuungs-cockpit/src/lib/crypto/totp.ts
T
Florian Wilde 609aa5d27d Anmeldung haerten: Einmalcodes, Anschluss, Host, Passkey-Vorzimmer
Vier Stellen, an denen eine Zusage nicht gehalten wurde.

1. Ein TOTP-Code galt mehrfach. Der Kommentar in totp.ts versprach seit
   dem Anfang, der Aufrufer speichere das getroffene Zeitfenster - getan
   hat es niemand. Damit liess sich ein abgelesener Code innerhalb von 90
   Sekunden noch einmal einloesen. Jetzt geht jede Pruefung durch
   zweitenFaktorEinloesen(), und die Bedingung steht im WHERE von
   updateMany: Zwei gleichzeitige Anmeldungen mit demselben Code treffen
   nicht beide eine Zeile.

2. Der Anschluss kam aus dem ERSTEN Eintrag von "x-forwarded-for". Das
   ist der Teil, den der Aufrufer selbst schreibt. Wer bei jeder Anfrage
   eine andere Zahl hineinschrieb, sah fuer die Anmeldesperre wie tausend
   Anschluesse aus - die Zaehlung war nicht nur wirkungslos, sondern
   irrefuehrend. Jetzt von hinten gelesen, und nur wenn VERTRAUE_PROXY
   gesetzt ist. Ohne Proxy ist der Kopf fremdbestimmt; dann lieber keine
   Zaehlung nach Anschluss als eine, die eine Kopfzeile aushebelt. Beim
   ersten Mal steht eine Zeile im Log, damit das Fehlen auffaellt.

3. trustHost stand fest auf true. Ist AUTH_URL gesetzt - der
   dokumentierte Betrieb setzt es, Passkeys brauchen es -, gibt es keinen
   Grund, die Adresse aus einer Kopfzeile von aussen abzuleiten.
   AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
   nicht stillschweigend uebergangen wird.

4. Passkeys liessen sich ohne erneute Bestaetigung anlegen und entfernen.
   Ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel
   ueberlebt - an einem unbeaufsichtigten Rechner waren das zwei Klicks.
   Jetzt dasselbe Vorzimmer wie beim Vollexport. Dabei musste
   alsFehler() lernen, die Weiterleitung von Next durchzulassen: Sonst
   verschwand die Zwischenfrage in "Der Passkey konnte nicht geprueft
   werden".

Refs #38
2026-08-24 08:03:33 +02:00

113 lines
3.7 KiB
TypeScript

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<string> {
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()
}