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
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