Der S3-Treiber setzte bei jedem PutObject fest
ServerSideEncryption: 'AES256'. Das mitgelieferte MinIO laeuft ohne KMS
und lehnt das mit "NotImplemented" ab - es liess sich kein einziges
Dokument ablegen. Wer der INSTALLATION.md folgte, landete zwangslaeufig
dort; es war kein Konfigurationsfehler des Betreibers, sondern der
Auslieferungszustand.
Gebraucht wird die Zeile ohnehin nicht mehr. Seit v1.11.0 verschluesselt
die Anwendung jede Datei selbst, bevor sie den Server verlaesst. Eine
zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene
Umschlag nicht schon abdeckt.
Jetzt steuert S3_SSE, ob der Kopf mitgeht. Leer bedeutet aus - auch eine
Zeile mit Leerzeichen, sonst schickte eine leergelassene .env-Zeile einen
leeren Kopf und der Anbieter antwortete mit einem Fehler, den niemand
versteht.
Im Kommentar von v1.11.0 stand, die serverseitige Verschluesselung
"kostet nichts". Das war falsch: Auf MinIO ohne KMS kostete sie die
gesamte Funktion. Der Satz ist korrigiert.
Aufgefallen ist das erst im Betrieb, und der Grund ist derselbe wie beim
Ausloeser: In der Entwicklung laeuft der Dateitreiber, der S3-Pfad wurde
von keinem Test ausgefuehrt. Deshalb pruefen die neuen Tests den Befehl
ab, bevor er das Netz sieht - was hineingeschrieben wird, ist jetzt
belegt, nicht angenommen.
Gegen drei Mutationen geprueft: SSE wieder fest verdrahtet, leere Angabe
nicht abgefangen, Inhalt unverschluesselt - jede wird rot.
Refs #42
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
Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg - auf der Anmeldeseite
und bei der erneuten Bestaetigung vor sensiblen Aktionen. Verwaltet unter
"Mein Konto".
Der Passkey ERSETZT den zweiten Faktor, statt zu ihm hinzuzukommen. Das
ist tragbar, weil er zwei Faktoren in sich vereint: Besitz des Geraets
und dessen Entsperrung. Damit das keine Annahme bleibt, wird die
Nutzerverifikation angefordert (userVerification: 'required') und
serverseitig nachgeprueft - eine Antwort ohne sie wird abgewiesen.
TOTP muss vorher eingerichtet sein. Ein Passkey haengt an einem Geraet;
waere er der einzige Weg hinein, koennte ein verlorenes Handy die Akte
schliessen.
Aufbau:
- Die Pruefung laeuft vollstaendig im Credentials-Provider "passkey",
nicht in der Server Action davor. Ein "war schon geprueft" ueber die
Grenze zu reichen waere die Stelle, an der sich Anmeldungen faelschen
lassen.
- Challenges liegen serverseitig und werden per DELETE genau einmal
verbraucht - die Datenbank entscheidet, wer sie bekommt.
- Bereichskennung und Herkunft kommen aus AUTH_URL, nicht aus einer
zweiten Konfigurationsquelle.
- Ohne https wird der Knopf gar nicht erst angeboten.
- Rate-Limiting, Kontosperre und Audit-Log gelten unveraendert; die
Anmeldung nennt den verwendeten Passkey namentlich.
Zwei Anmerkungen zur Migration:
- "prisma migrate diff" wollte zusaetzlich die Trigramm-Indizes aus
prisma/sql/suche.sql loeschen, die Prisma nicht kennt. Die Zeilen sind
entfernt und im Migrationskopf vermerkt - ein DROP haette die unscharfe
Suche stillgelegt, ohne dass etwas fehlgeschlagen waere.
- "prisma format" hat das Schema durchgaengig neu ausgerichtet. Ohne
Whitespace betrachtet sind es 88 hinzugefuegte Zeilen und keine
geloeschte.
Geprueft: 21 Einheitstests, drei End-to-End-Tests gegen Chromiums
virtuellen Authenticator (einrichten, anmelden, bestaetigen, entfernen,
Geraet ohne Verifikation abgewiesen), beide Geraeteprofile. 496 Tests,
null Typfehler, Produktionsbau steht.
Nebenbei: Die Zahlen im README waren veraltet (24 statt 33 Modelle, 46
statt 79 Seiten). Sie stehen jetzt mit dem Befehl daneben, mit dem man
sie nachzaehlt.
docker-compose.yml reicht ENC_KEY_V2 immer an den Behaelter durch - mit
leerem Wert, wenn in der .env keiner steht. src/lib/env.ts erkannte
Schluessel aber am Variablennamen statt am Inhalt, fand also ein leeres
ENC_KEY_V2 und brach ab mit "Umgebungsvariable ENC_KEY_V2 fehlt".
Die Meldung war doppelt irrefuehrend: Sie nannte einen Schluessel, den
niemand wollte, und sie erschien an einer Stelle, die damit nichts zu tun
hat - beim Einrichten des zweiten Faktors, als Serverfehler ohne Text.
Betroffen war jede Stelle, die verschluesselte Felder schreibt oder
liest, also praktisch alles ausser der Anmeldung selbst. Die kam durch,
weil sie ohne Verschluesselung auskommt - der Fehler trat deshalb erst
auf der naechsten Seite auf, und der Zusammenhang war nicht zu sehen.
Leere Schluesselvariablen werden jetzt uebersprungen. Sieben Tests decken
die Auswertung ab, darunter genau diese Konstellation.
Die Meldung beim Schluesselwechsel nennt jetzt ausserdem, welche
Versionen vorhanden sind, und die richtige Reihenfolge: erst den neuen
Schluessel eintragen, dann ENC_KEY_AKTIV umstellen.
Dazu scripts/anmeldung-pruefen.mjs. Das Anmeldeformular sagt bewusst
nicht, woran eine Anmeldung scheiterte - sonst liesse sich daran ablesen,
welche Adressen es gibt. Auf dem Server darf man es wissen: Jeder Versuch
wird mit Grund protokolliert, und das Skript liest ihn vor, zusammen mit
dem Zustand des Zugangs, dem Stand der Sperre und der AUTH_URL.
Etappe: 8
Phase: Haertung
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