From 4cce84c7dd957d8f1f9f7d211edb18ad8094b984 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 20 Aug 2026 12:30:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Warnung=20+=20selbstverwalteter=20Wiede?= =?UTF-8?q?rherstellungscode=20f=C3=BCr=20verlorenen=20Sync-Schl=C3=BCssel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LehrerApp.Desktop.Tests/Fakes.cs | 13 ++ .../SettingsViewModelTests.cs | 120 ++++++++++++++- LehrerApp.Desktop/AppBootstrapper.cs | 12 ++ LehrerApp.Desktop/Services/SyncKeyStatus.cs | 13 ++ .../ViewModels/Settings/SettingsViewModel.cs | 83 +++++++++- .../Views/Settings/SettingsView.axaml | 39 +++++ .../Views/Settings/SettingsView.axaml.cs | 47 ++++++ LehrerApp.Sync.Tests/SyncCryptoTests.cs | 145 ++++++++++++++++++ .../SyncKeyRecoveryServiceTests.cs | 61 ++++++++ LehrerApp.Sync/Crypto/SyncCrypto.cs | 57 +++++++ LehrerApp.Sync/SyncKeyRecoveryService.cs | 32 ++++ TODO.md | 47 +++++- 12 files changed, 662 insertions(+), 7 deletions(-) create mode 100644 LehrerApp.Desktop/Services/SyncKeyStatus.cs create mode 100644 LehrerApp.Sync.Tests/SyncCryptoTests.cs create mode 100644 LehrerApp.Sync.Tests/SyncKeyRecoveryServiceTests.cs create mode 100644 LehrerApp.Sync/SyncKeyRecoveryService.cs diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index b750865..84b3479 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -3,6 +3,7 @@ using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using LehrerApp.Sync; +using LehrerApp.Sync.Crypto; namespace LehrerApp.Desktop.Tests; @@ -64,6 +65,18 @@ public static class TestSupport Directory.CreateDirectory(tempPath); return new AppLogger(tempPath); } + + /// Für Tests, die nicht speziell den "Schlüssel neu erzeugt"-Warnzustand prüfen. + public static SyncKeyStatus BuildSyncKeyStatus(bool keyWasRegenerated = false) => new(keyWasRegenerated); + + /// Eigenes Temp-Verzeichnis je Aufruf, damit Tests sich nicht gegenseitig über dieselbe + /// sync.key stören. + public static SyncKeyRecoveryService BuildSyncKeyRecoveryService() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-synckeyrecovery-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), Path.Combine(tempPath, "sync.key")); + } } public class FakeStudents(List all) : IStudentRepository diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 7dcd607..7119e7b 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -1,6 +1,7 @@ using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Data; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Sync; using LehrerApp.Sync.Models; @@ -14,7 +15,8 @@ public sealed class SettingsViewModelTests private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null, FakeSupervisionDuties? supervisionDuties = null, FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null, - EventQueue? eventQueue = null) + EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null, + SyncKeyRecoveryService? syncKeyRecovery = null) { // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState, // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. @@ -32,7 +34,115 @@ public sealed class SettingsViewModelTests new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), - eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger()); + eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), + syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(), + syncKeyRecovery ?? TestSupport.BuildSyncKeyRecoveryService()); + } + + // ── Sync-Schlüssel: Warnung + Wiederherstellungscode (10.3.2) ──────────────────────────── + // + // Der "glückliche" Redeem-Pfad (gültiger Code + Datei + Bestätigung) endet in + // AppBootstrapper.RestartApplication(), das Environment.Exit(0) aufruft - das darf ein Test + // niemals auslösen (würde den Testlauf beenden). Deshalb sind hier nur die Zweige getestet, + // die VOR diesem Aufruf zurückkehren; genau dasselbe Muster gilt bereits für + // RedeemPairingCode, das aus demselben Grund ebenfalls keinen Erfolgstest hat. Der + // eigentliche Kryptografie-/Datei-Roundtrip ist in SyncKeyRecoveryServiceTests abgedeckt. + + [Fact] + public void SyncKeyWasRegenerated_SpiegeltDenBeimStartErkanntenStatusWider() + { + var vm = BuildViewModel(syncKeyStatus: new SyncKeyStatus(keyWasRegenerated: true)); + + Assert.True(vm.SyncKeyWasRegenerated); + } + + [Fact] + public void SyncKeyWasRegenerated_NormalerStart_IstFalse() + { + var vm = BuildViewModel(syncKeyStatus: new SyncKeyStatus(keyWasRegenerated: false)); + + Assert.False(vm.SyncKeyWasRegenerated); + } + + [Fact] + public async Task CreateRecoveryCode_SpeichernErfolgreich_ZeigtCodeUndUebergibtDateiinhalt() + { + var vm = BuildViewModel(); + string? savedContent = null; + vm.OnSaveRecoveryFile = content => { savedContent = content; return Task.FromResult(true); }; + + await vm.CreateRecoveryCodeCommand.ExecuteAsync(null); + + Assert.NotEmpty(vm.RecoveryCode); + Assert.NotNull(savedContent); + Assert.Contains("notieren", vm.RecoveryStatus); + } + + [Fact] + public async Task CreateRecoveryCode_AbbruchBeimSpeichern_ZeigtKeinenCode() + { + var vm = BuildViewModel(); + vm.OnSaveRecoveryFile = _ => Task.FromResult(false); + + await vm.CreateRecoveryCodeCommand.ExecuteAsync(null); + + Assert.Empty(vm.RecoveryCode); + Assert.Equal("Abgebrochen.", vm.RecoveryStatus); + } + + [Fact] + public async Task RedeemRecoveryCode_LeererCode_FragtNieNachEinerDatei() + { + var vm = BuildViewModel(); + var filePickerCalled = false; + vm.OnPickRecoveryFile = () => { filePickerCalled = true; return Task.FromResult(null); }; + vm.RecoveryCodeInput = " "; + + await vm.RedeemRecoveryCodeCommand.ExecuteAsync(null); + + Assert.False(filePickerCalled); + Assert.Equal("Bitte Code eingeben.", vm.RecoveryStatus); + } + + [Fact] + public async Task RedeemRecoveryCode_DateiauswahlAbgebrochen_TutNichts() + { + var vm = BuildViewModel(); + vm.OnPickRecoveryFile = () => Task.FromResult(null); + vm.RecoveryCodeInput = "IRGENDEIN-CODE"; + + var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null)); + + Assert.Null(exception); + } + + [Fact] + public async Task RedeemRecoveryCode_FalscherCode_ZeigtFehlerUndWirftNicht() + { + var recovery = TestSupport.BuildSyncKeyRecoveryService(); + var vm = BuildViewModel(syncKeyRecovery: recovery); + vm.OnPickRecoveryFile = () => Task.FromResult(recovery.CreateRecoveryFile("RICHTIGER-CODE")); + vm.OnConfirmRecoveryRestore = () => Task.FromResult(true); + vm.RecoveryCodeInput = "FALSCHER-CODE"; + + var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null)); + + Assert.Null(exception); + Assert.Contains("passen nicht zusammen", vm.RecoveryStatus); + } + + [Fact] + public async Task RedeemRecoveryCode_BestaetigungAbgelehnt_TutNichts() + { + var recovery = TestSupport.BuildSyncKeyRecoveryService(); + var vm = BuildViewModel(syncKeyRecovery: recovery); + vm.OnPickRecoveryFile = () => Task.FromResult(recovery.CreateRecoveryFile("EIN-CODE")); + vm.OnConfirmRecoveryRestore = () => Task.FromResult(false); + vm.RecoveryCodeInput = "EIN-CODE"; + + var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null)); + + Assert.Null(exception); } [Fact] @@ -149,7 +259,7 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), - TestSupport.BuildAppLogger()); + TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService()); vm.SelectedStateName = "Bayern"; @@ -173,7 +283,7 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), - TestSupport.BuildAppLogger()); + TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService()); vm.PeriodTimes[0].StartText = "08:00"; vm.PeriodTimes[0].EndText = "08:45"; @@ -201,7 +311,7 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), - TestSupport.BuildAppLogger()); + TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService()); vm.PeriodTimes[0].StartText = "08:45"; vm.PeriodTimes[0].EndText = "08:00"; diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index a7ad607..bb2b906 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -105,6 +105,9 @@ public static class AppBootstrapper ResolveDbPath(); var queuePath = Path.Combine(appData, "syncqueue.db"); var keyPath = Path.Combine(appData, "sync.key"); + // Muss VOR jedem möglichen Zugriff auf keyPath erfasst werden (siehe SyncKeyStatus unten) - + // File.Exists ist hier synchron und unabhängig von der Lazy-Auflösung der DI-Factories. + var keyExistedBefore = File.Exists(keyPath); // ── Logging & Benachrichtigungen ───────────────────────────────────────── EnsureLogger(); @@ -183,6 +186,12 @@ public static class AppBootstrapper var syncSettings = new SyncSettingsService(appData); services.AddSingleton(syncSettings); services.AddSingleton(_ => new SyncAuthService(new HttpClient())); + // War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten) + // stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN + // Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar. + // Bei einem Gerät, das noch nie eingeloggt war, ist ein fehlender Schlüssel dagegen der + // normale Erstlauf. Siehe SettingsViewModel (Warnbanner) und SyncKeyRecoveryService. + services.AddSingleton(new SyncKeyStatus(!keyExistedBefore && syncSettings.IsLoggedIn)); services.AddSingleton(_ => new EventQueue(queuePath)); services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService())); @@ -192,6 +201,9 @@ public static class AppBootstrapper SyncCrypto.SaveKey(key, keyPath); return key; }); + // Rein lokal/offline (kein Server-Zugriff nötig), deshalb unconditional registriert - im + // Unterschied zu SnapshotService unten, das eine konfigurierte Server-URL voraussetzt. + services.AddSingleton(sp => new SyncKeyRecoveryService(sp.GetRequiredService(), keyPath)); var serverUrl = syncSettings.ServerUrl; var deviceId = LoadOrCreateDeviceId(appData); diff --git a/LehrerApp.Desktop/Services/SyncKeyStatus.cs b/LehrerApp.Desktop/Services/SyncKeyStatus.cs new file mode 100644 index 0000000..94c3612 --- /dev/null +++ b/LehrerApp.Desktop/Services/SyncKeyStatus.cs @@ -0,0 +1,13 @@ +namespace LehrerApp.Desktop.Services; + +/// +/// Erkennt, ob der lokale Sync-Schlüssel (sync.key) auf diesem Gerät fehlte, obwohl Sync hier +/// bereits aktiv war — dann hat AppBootstrapper beim Start automatisch einen neuen, unabhängigen +/// Schlüssel erzeugt, und alle bisher unter dem ALTEN Schlüssel synchronisierten Server-Daten sind +/// für dieses Gerät nicht mehr lesbar (TODO 10.3.2). Ein fehlender Schlüssel bei einem Gerät, das +/// noch nie eingeloggt war, ist dagegen der normale Erstlauf — kein Grund zur Warnung. +/// +public class SyncKeyStatus(bool keyWasRegenerated) +{ + public bool KeyWasRegenerated { get; } = keyWasRegenerated; +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 29b3545..1583ca4 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -7,8 +7,10 @@ using LehrerApp.Data; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Sync; +using LehrerApp.Sync.Crypto; using System.Collections.ObjectModel; using System.Globalization; +using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; @@ -197,6 +199,21 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private string _syncConnectionStatus = ""; [ObservableProperty] private string _syncForceResyncStatus = ""; [ObservableProperty] private bool _syncForceResyncBusy; + [ObservableProperty] private bool _syncKeyWasRegenerated; + [ObservableProperty] private string _recoveryCode = ""; + [ObservableProperty] private string _recoveryCodeInput = ""; + [ObservableProperty] private string _recoveryStatus = ""; + [ObservableProperty] private bool _recoveryBusy; + + /// Vom Code-Behind gesetzt: lässt den Nutzer die Wiederherstellungsdatei selbst speichern + /// (z.B. USB-Stick, eigene Cloud). Liefert false bei Abbruch. + public Func>? OnSaveRecoveryFile { get; set; } + /// Vom Code-Behind gesetzt: lässt den Nutzer eine zuvor gesicherte Wiederherstellungsdatei + /// auswählen. Liefert null bei Abbruch. + public Func>? OnPickRecoveryFile { get; set; } + /// Vom Code-Behind gesetzt: bestätigt vor dem Überschreiben des aktuellen Sync-Schlüssels + /// dieses Geräts (die App startet danach neu, siehe RedeemRecoveryCode). + public Func>? OnConfirmRecoveryRestore { get; set; } public ObservableCollection SyncConflicts { get; } = []; @@ -233,6 +250,7 @@ public partial class SettingsViewModel : ObservableObject private readonly EventQueue _eventQueue; private readonly SyncEngine? _syncEngine; private readonly SnapshotService? _snapshotService; + private readonly SyncKeyRecoveryService _syncKeyRecovery; private readonly CompetencyCatalogImportService _catalogImport; private readonly AppLogger _logger; @@ -246,9 +264,12 @@ public partial class SettingsViewModel : ObservableObject ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, AiSettingsService aiSettings, AiPlanningService aiPlanning, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, - AppLogger logger, SnapshotService? snapshotService = null, SyncEngine? syncEngine = null) + AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery, + SnapshotService? snapshotService = null, SyncEngine? syncEngine = null) { _logger = logger; + _syncKeyRecovery = syncKeyRecovery; + SyncKeyWasRegenerated = syncKeyStatus.KeyWasRegenerated; _subjects = subjects; _domainRepo = domainRepo; _gradingKeyTemplates = gradingKeyTemplates; @@ -534,6 +555,66 @@ public partial class SettingsViewModel : ObservableObject finally { SyncForceResyncBusy = false; } } + // ── Sync-Schlüssel: selbstverwalteter Wiederherstellungscode (10.3.2) ──────────────────── + // + // Der Sync-Schlüssel verlässt nie den Server im Klartext (siehe SyncCrypto) - eine echte + // Wiederherstellung nach Verlust des einzigen Geräts mit dem Schlüssel ist deshalb nur + // möglich, wenn vorher proaktiv ein Rettungsanker angelegt wurde. "Code erzeugen" + // verschlüsselt den aktuellen Schlüssel mit einem einmalig angezeigten, zufälligen Code + // (SyncCrypto.EncryptKeyWithRecoveryCode) 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. Läuft komplett offline, ohne Server-Beteiligung. + + [RelayCommand] + private async Task CreateRecoveryCode() + { + if (OnSaveRecoveryFile is null) return; + RecoveryBusy = true; + RecoveryStatus = ""; + RecoveryCode = ""; + try + { + var code = SyncCrypto.GenerateRecoveryCode(); + var fileContent = _syncKeyRecovery.CreateRecoveryFile(code); + if (!await OnSaveRecoveryFile(fileContent)) { RecoveryStatus = "Abgebrochen."; return; } + RecoveryCode = code; + RecoveryStatus = "Datei gespeichert. Den Code getrennt von der Datei notieren und " + + "sicher aufbewahren - er wird nirgends gespeichert und lässt sich " + + "nicht erneut anzeigen."; + } + finally { RecoveryBusy = false; } + } + + [RelayCommand] + private async Task RedeemRecoveryCode() + { + RecoveryStatus = ""; + if (string.IsNullOrWhiteSpace(RecoveryCodeInput)) { RecoveryStatus = "Bitte Code eingeben."; return; } + if (OnPickRecoveryFile is null) return; + var fileContent = await OnPickRecoveryFile(); + if (fileContent is null) return; + if (OnConfirmRecoveryRestore is not null && !await OnConfirmRecoveryRestore()) return; + + try + { + _syncKeyRecovery.RestoreFromFile(fileContent, RecoveryCodeInput.Trim()); + } + catch (Exception ex) when (ex is CryptographicException or InvalidOperationException or JsonException) + { + RecoveryStatus = "Fehlgeschlagen: Code und Datei passen nicht zusammen."; + _logger.Error("Sync-Schlüssel-Wiederherstellung fehlgeschlagen", ex); + return; + } + // Der neue Schlüssel liegt jetzt auf der Platte, aber der aktuell im Speicher gehaltene + // (falsche) Schlüssel ist ein DI-Singleton und kann zur Laufzeit nicht getauscht werden - + // gleiches Muster wie RedeemPairingCode. Lokale Cursor/Versionsverfolgung zurücksetzen, + // falls in der Zwischenzeit (mit dem gerade ersetzten, falschen Schlüssel) bereits etwas + // synchronisiert wurde - sicher wiederholbar, siehe SyncForceFullResync. + _eventQueue.SetLastServerSeq(0); + _eventQueue.ResetKnownServerSeqs(); + AppBootstrapper.RestartApplication(); + } + // ── Synchronisation: Konflikte ──────────────────────────────────────────── // // Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index 055b9fd..b096634 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -737,6 +737,15 @@ + + + + + + + @@ -786,6 +795,36 @@ IsVisible="{Binding SyncForceResyncStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> + + + + + + + + +