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
@@ -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<string?>(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<string?>(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<string?>(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<string?>(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";