feat: Warnung + selbstverwalteter Wiederherstellungscode für verlorenen Sync-Schlüssel

Der Sync-Schlüssel verlässt nie den Server im Klartext - verliert das einzige Gerät sync.key,
erzeugte AppBootstrapper bisher stillschweigend einen neuen, unabhängigen Schlüssel, ohne dass
der Nutzer etwas davon merkt. Bereits synchronisierte Server-Daten werden dadurch dauerhaft
unlesbar.

AppBootstrapper erkennt jetzt, ob ein fehlender Schlüssel bei einem bereits eingeloggten Gerät
auftritt (SyncKeyStatus) - SettingsViewModel zeigt dafür ein Warnbanner im Synchronisation-Tab mit
der Empfehlung, im Zweifel über ein noch intaktes zweites Gerät neu zu koppeln.

Zusätzlich ein komplett offline laufender, selbstverwalteter Wiederherstellungscode (kein neuer
dauerhafter Server-Speicher für ein sicherheitsrelevantes Geheimnis): SyncCrypto bekommt einen
neuen, vom Pairing-Code kryptografisch unabhängigen Recovery-Code (120 Bit, Base32), mit dem der
Sync-Schlüssel lokal verschlüsselt und als Datei durch den Nutzer selbst gesichert werden kann.
Neuer SyncKeyRecoveryService kapselt Erzeugen/Einlösen; Redeem schreibt den wiederhergestellten
Schlüssel und setzt vorsorglich Pull-Cursor/Versionsverfolgung zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 12:30:55 +02:00
co-authored by Claude Sonnet 5
parent c5a1157a7d
commit 4cce84c7dd
12 changed files with 662 additions and 7 deletions
+46 -1
View File
@@ -1989,7 +1989,52 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
reicht den in Schritt 1 erhaltenen Code in Schritt 2 zurück. **Wichtig für den Rollout:**
diese Änderung betrifft `LehrerApp.Api` (Server) — ein reines Neubauen des Desktop-Clients
reicht nicht, der Server muss neu deployt werden.
- [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
- [x] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
**Ausgangslage:** Der Sync-Schlüssel verlässt nie den Server im Klartext (siehe
`SyncCrypto`-Kopfkommentar) — verliert das einzige Gerät mit dem Schlüssel `sync.key`
(Neuinstallation, defekte Platte), erzeugt `AppBootstrapper` bisher stillschweigend einen
neuen, unabhängigen Schlüssel; bereits synchronisierte Server-Daten werden für dieses Gerät
dauerhaft unlesbar, ohne dass der Nutzer etwas davon merkt. Eine ECHTE Wiederherstellung
danach ist nur möglich, wenn vorher proaktiv ein Rettungsanker angelegt wurde — dem Nutzer
zur Wahl gestellt (drei Stufen: nur Warnung / selbstverwalteter Code / dauerhafte
Server-Hinterlegung), Entscheidung: **selbstverwalteter Recovery-Code, kein neuer
dauerhafter Server-Speicher** für ein sicherheitsrelevantes Geheimnis.
**Umsetzung, zweiteilig:**
1. **Warnung.** `AppBootstrapper` erfasst `keyExistedBefore = File.Exists(keyPath)`
synchron VOR jedem möglichen Schreibzugriff und registriert `SyncKeyStatus
{ KeyWasRegenerated = !keyExistedBefore && syncSettings.IsLoggedIn }` — ein fehlender
Schlüssel bei einem noch nie eingeloggten Gerät ist der normale Erstlauf, kein Grund zur
Warnung. `SettingsViewModel` zeigt bei `SyncKeyWasRegenerated` ein rotes Banner oben im
Synchronisation-Tab mit der konkreten Handlungsempfehlung: solange noch ein anderes Gerät
den alten Schlüssel hat, DAMIT neu koppeln (10.3.1, bereits vorhanden); sonst hilft nur
ein zuvor gesicherter Recovery-Code.
2. **Wiederherstellungscode**, komplett offline, ohne Server-Beteiligung. Neue
`SyncCrypto`-Bausteine mit eigenem Salt (bewusst getrennt von `DeriveKeyFromCode` des
kurzlebigen Pairing-Codes, damit ein Schema-Wechsel am einen den anderen nicht
mitbeeinflusst): `GenerateRecoveryCode()` (15 Byte Zufall, 120 Bit, Base32 in 4er-Gruppen,
z. B. `ABCD-EFGH-…`), `EncryptKeyWithRecoveryCode`/`DecryptKeyWithRecoveryCode`
(PBKDF2-Ableitung, Code-Eingabe tolerant gegenüber Klein-/Großschreibung und fehlenden
Trennstrichen). Neuer `SyncKeyRecoveryService` (analog `SnapshotService`, aber ohne
HTTP): "Code erzeugen" verschlüsselt den aktuellen Schlüssel mit einem einmalig
angezeigten Code und lässt den Nutzer die verschlüsselte Datei selbst sichern (USB-Stick,
eigene Cloud o. ä.) — Datei und Code getrennt aufbewahren, erst beides zusammen ergibt
den Schlüssel. "Wiederherstellungscode einlösen" liest Datei + Code, schreibt den
entschlüsselten Schlüssel als neues `sync.key`, setzt vorsorglich Pull-Cursor und
Push-Versionsverfolgung zurück (falls zwischenzeitlich unter dem falschen Schlüssel schon
etwas synchronisiert wurde, analog 10.3.5) und startet die App neu — der im Speicher
gehaltene (falsche) Schlüssel ist ein DI-Singleton und kann zur Laufzeit nicht getauscht
werden, gleiches Muster wie `RedeemPairingCode`.
**Bewusst nicht getestet:** der erfolgreiche Redeem-Pfad endet in
`AppBootstrapper.RestartApplication()` (`Environment.Exit(0)`) — das darf ein Test nie
auslösen, deshalb sind nur die Zweige VOR diesem Aufruf ViewModel-getestet; derselbe Grund,
aus dem `RedeemPairingCode` ebenfalls keinen Erfolgstest hat. Neue Tests: `SyncCryptoTests`
(neu, bisher gab es keine dedizierte Testdatei — Schlüsselerzeugung, Verschlüsselungs-
Rundtrip, Code-Rundtrip inkl. Fehlertoleranz, eigenständige Ableitung ggü. dem
Pairing-Salt), `SyncKeyRecoveryServiceTests` (Datei-Rundtrip, falscher Code, ungültiges
Dateiformat), `SettingsViewModelTests` (Warnzustand, Code-Erzeugung inkl. Abbruch,
Redeem-Guards).
- [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus
Notenwert/-kommentar) trugen bisher personenbezogene Daten im Klartext über den