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
+13
View File
@@ -3,6 +3,7 @@ using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Sync; using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -64,6 +65,18 @@ public static class TestSupport
Directory.CreateDirectory(tempPath); Directory.CreateDirectory(tempPath);
return new AppLogger(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<Student> all) : IStudentRepository public class FakeStudents(List<Student> all) : IStudentRepository
@@ -1,6 +1,7 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync; using LehrerApp.Sync;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
@@ -14,7 +15,8 @@ public sealed class SettingsViewModelTests
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null, private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSupervisionDuties? supervisionDuties = null,
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = 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, // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. // 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 PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), 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] [Fact]
@@ -149,7 +259,7 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger()); TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService());
vm.SelectedStateName = "Bayern"; vm.SelectedStateName = "Bayern";
@@ -173,7 +283,7 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger()); TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService());
vm.PeriodTimes[0].StartText = "08:00"; vm.PeriodTimes[0].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45"; vm.PeriodTimes[0].EndText = "08:45";
@@ -201,7 +311,7 @@ public sealed class SettingsViewModelTests
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger()); TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService());
vm.PeriodTimes[0].StartText = "08:45"; vm.PeriodTimes[0].StartText = "08:45";
vm.PeriodTimes[0].EndText = "08:00"; vm.PeriodTimes[0].EndText = "08:00";
+12
View File
@@ -105,6 +105,9 @@ public static class AppBootstrapper
ResolveDbPath(); ResolveDbPath();
var queuePath = Path.Combine(appData, "syncqueue.db"); var queuePath = Path.Combine(appData, "syncqueue.db");
var keyPath = Path.Combine(appData, "sync.key"); 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 ───────────────────────────────────────── // ── Logging & Benachrichtigungen ─────────────────────────────────────────
EnsureLogger(); EnsureLogger();
@@ -183,6 +186,12 @@ public static class AppBootstrapper
var syncSettings = new SyncSettingsService(appData); var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings); services.AddSingleton(syncSettings);
services.AddSingleton(_ => new SyncAuthService(new HttpClient())); 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(_ => new EventQueue(queuePath));
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>())); services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
@@ -192,6 +201,9 @@ public static class AppBootstrapper
SyncCrypto.SaveKey(key, keyPath); SyncCrypto.SaveKey(key, keyPath);
return key; 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<byte[]>(), keyPath));
var serverUrl = syncSettings.ServerUrl; var serverUrl = syncSettings.ServerUrl;
var deviceId = LoadOrCreateDeviceId(appData); var deviceId = LoadOrCreateDeviceId(appData);
@@ -0,0 +1,13 @@
namespace LehrerApp.Desktop.Services;
/// <summary>
/// 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.
/// </summary>
public class SyncKeyStatus(bool keyWasRegenerated)
{
public bool KeyWasRegenerated { get; } = keyWasRegenerated;
}
@@ -7,8 +7,10 @@ using LehrerApp.Data;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Sync; using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@@ -197,6 +199,21 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _syncConnectionStatus = ""; [ObservableProperty] private string _syncConnectionStatus = "";
[ObservableProperty] private string _syncForceResyncStatus = ""; [ObservableProperty] private string _syncForceResyncStatus = "";
[ObservableProperty] private bool _syncForceResyncBusy; [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<string, Task<bool>>? OnSaveRecoveryFile { get; set; }
/// Vom Code-Behind gesetzt: lässt den Nutzer eine zuvor gesicherte Wiederherstellungsdatei
/// auswählen. Liefert null bei Abbruch.
public Func<Task<string?>>? 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<Task<bool>>? OnConfirmRecoveryRestore { get; set; }
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = []; public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
@@ -233,6 +250,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly EventQueue _eventQueue; private readonly EventQueue _eventQueue;
private readonly SyncEngine? _syncEngine; private readonly SyncEngine? _syncEngine;
private readonly SnapshotService? _snapshotService; private readonly SnapshotService? _snapshotService;
private readonly SyncKeyRecoveryService _syncKeyRecovery;
private readonly CompetencyCatalogImportService _catalogImport; private readonly CompetencyCatalogImportService _catalogImport;
private readonly AppLogger _logger; private readonly AppLogger _logger;
@@ -246,9 +264,12 @@ public partial class SettingsViewModel : ObservableObject
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, AiSettingsService aiSettings, AiPlanningService aiPlanning,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, 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; _logger = logger;
_syncKeyRecovery = syncKeyRecovery;
SyncKeyWasRegenerated = syncKeyStatus.KeyWasRegenerated;
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
_gradingKeyTemplates = gradingKeyTemplates; _gradingKeyTemplates = gradingKeyTemplates;
@@ -534,6 +555,66 @@ public partial class SettingsViewModel : ObservableObject
finally { SyncForceResyncBusy = false; } 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 ──────────────────────────────────────────── // ── Synchronisation: Konflikte ────────────────────────────────────────────
// //
// Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein // Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein
@@ -737,6 +737,15 @@
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap" <TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Gleicht Daten zwischen mehreren eigenen Geräten über einen selbst betriebenen Server ab. Erfordert einen bereits eingerichteten Server und einen dort angelegten Nutzer."/> Text="Gleicht Daten zwischen mehreren eigenen Geräten über einen selbst betriebenen Server ab. Erfordert einen bereits eingerichteten Server und einen dort angelegten Nutzer."/>
<Border Background="#3DE53935" BorderBrush="#E53935" BorderThickness="1" CornerRadius="6"
Padding="12,10" IsVisible="{Binding SyncKeyWasRegenerated}">
<StackPanel Spacing="4">
<TextBlock Text="Neuer Sync-Schlüssel erzeugt" FontSize="13" FontWeight="Bold"/>
<TextBlock FontSize="12" TextWrapping="Wrap"
Text="Der bisherige Sync-Schlüssel dieses Geräts fehlte beim Start und wurde automatisch durch einen neuen ersetzt. Bereits synchronisierte Daten unter dem alten Schlüssel sind für dieses Gerät dadurch nicht mehr lesbar. Hast du noch ein anderes Gerät mit dem alten Schlüssel, koppele dieses Gerät DAMIT neu (unten „Gerät koppeln“), statt fortzufahren. Ansonsten hilft nur ein zuvor gesicherter Wiederherstellungscode (unten)."/>
</StackPanel>
</Border>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="Server-Adresse" FontSize="12" Opacity="0.7"/> <TextBlock Text="Server-Adresse" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding SyncServerUrl}" PlaceholderText="https://sync.example.com"/> <TextBox Text="{Binding SyncServerUrl}" PlaceholderText="https://sync.example.com"/>
@@ -786,6 +795,36 @@
IsVisible="{Binding SyncForceResyncStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding SyncForceResyncStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="10" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}">
<Separator/>
<TextBlock Text="Wiederherstellungscode" FontSize="14" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Der Sync-Schlüssel verlässt nie den Server im Klartext — geht er auf allen Geräten verloren, sind bereits synchronisierte Daten ohne einen vorher gesicherten Code unwiederbringlich. Der Code läuft komplett offline, ohne Server."/>
<StackPanel Spacing="6">
<TextBlock Text="Code erzeugen und Datei sichern" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Speichert eine verschlüsselte Schlüsseldatei an einem Ort deiner Wahl (z. B. USB-Stick, eigene Cloud). Den anschließend angezeigten Code getrennt davon notieren und sicher aufbewahren — er wird nirgends gespeichert."/>
<Button Content="Code erzeugen" Command="{Binding CreateRecoveryCodeCommand}"
IsEnabled="{Binding !RecoveryBusy}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding RecoveryCode}" FontSize="18" FontWeight="Bold"
FontFamily="Monospace" TextWrapping="Wrap"
IsVisible="{Binding RecoveryCode, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Wiederherstellungscode einlösen" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Ersetzt den Sync-Schlüssel DIESES Geräts durch den aus der gesicherten Datei — nur nötig, wenn der eigene Schlüssel verloren ging."/>
<TextBox Text="{Binding RecoveryCodeInput}" PlaceholderText="Code eingeben"/>
<Button Content="Datei auswählen und wiederherstellen" Command="{Binding RedeemRecoveryCodeCommand}"
IsEnabled="{Binding !RecoveryBusy}" HorizontalAlignment="Left"/>
</StackPanel>
<TextBlock Text="{Binding RecoveryStatus}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding RecoveryStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="10" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}"> <StackPanel Spacing="10" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}">
<Separator/> <Separator/>
<TextBlock Text="Gerät koppeln" FontSize="14" FontWeight="SemiBold"/> <TextBlock Text="Gerät koppeln" FontSize="14" FontWeight="SemiBold"/>
@@ -22,9 +22,56 @@ public partial class SettingsView : UserControl
vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog; vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog;
vm.OnConfirmPairingRestore = ShowPairingRestoreConfirmDialog; vm.OnConfirmPairingRestore = ShowPairingRestoreConfirmDialog;
vm.OnShowPairingError = ShowPairingErrorDialog; vm.OnShowPairingError = ShowPairingErrorDialog;
vm.OnSaveRecoveryFile = SaveRecoveryFile;
vm.OnPickRecoveryFile = PickRecoveryFile;
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
} }
} }
private async Task<bool> SaveRecoveryFile(string content)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return false;
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Sync-Wiederherstellungsdatei speichern",
SuggestedFileName = "LehrerApp-Sync-Wiederherstellung.json",
FileTypeChoices = [new FilePickerFileType("JSON-Dateien") { Patterns = ["*.json"] }],
});
if (file is null) return false;
await File.WriteAllTextAsync(file.Path.LocalPath, content);
return true;
}
private async Task<string?> PickRecoveryFile()
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return null;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Sync-Wiederherstellungsdatei auswählen",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("JSON-Dateien") { Patterns = ["*.json"] }],
});
return files.Count == 0 ? null : await File.ReadAllTextAsync(files[0].Path.LocalPath);
}
private async Task<bool> ShowRecoveryRestoreConfirmDialog()
{
var info = new ConfirmDialogInfo
{
Title = "Sync-Schlüssel ersetzen?",
Message = "Der aktuelle Sync-Schlüssel dieses Geräts wird durch den aus der Datei ersetzt. " +
"Die App wird danach automatisch neu gestartet.",
ConfirmText = "Ersetzen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async Task<bool> ShowRestoreConfirmDialog(BackupListItem item) private async Task<bool> ShowRestoreConfirmDialog(BackupListItem item)
{ {
var info = new ConfirmDialogInfo var info = new ConfirmDialogInfo
+145
View File
@@ -0,0 +1,145 @@
using System.Security.Cryptography;
using LehrerApp.Sync.Crypto;
using Xunit;
namespace LehrerApp.Sync.Tests;
public sealed class SyncCryptoTests
{
[Fact]
public void GenerateKey_LiefertZweiUnterschiedlicheSchluessel()
{
var a = SyncCrypto.GenerateKey();
var b = SyncCrypto.GenerateKey();
Assert.Equal(32, a.Length);
Assert.NotEqual(a, b);
}
[Fact]
public void SaveKeyUndLoadKey_Roundtrip()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-synccrypto-tests-{Guid.NewGuid():N}.key");
try
{
var key = SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, path);
Assert.Equal(key, SyncCrypto.LoadKey(path));
}
finally { if (File.Exists(path)) File.Delete(path); }
}
[Fact]
public void LoadKey_DateiFehlt_LiefertNull() =>
Assert.Null(SyncCrypto.LoadKey(Path.Combine(Path.GetTempPath(), $"nie-existiert-{Guid.NewGuid():N}.key")));
[Fact]
public void EncryptUndDecrypt_Roundtrip()
{
var key = SyncCrypto.GenerateKey();
var plaintext = "Ein geheimer Sitzplan-Sync-Test"u8.ToArray();
var encrypted = SyncCrypto.Encrypt(plaintext, key);
var decrypted = SyncCrypto.Decrypt(encrypted, key);
Assert.Equal(plaintext, decrypted);
}
[Fact]
public void Decrypt_FalscherSchluessel_WirftCryptographicException()
{
var encrypted = SyncCrypto.Encrypt("geheim"u8.ToArray(), SyncCrypto.GenerateKey());
Assert.ThrowsAny<CryptographicException>(() => SyncCrypto.Decrypt(encrypted, SyncCrypto.GenerateKey()));
}
[Fact]
public void EncryptKeyWithCodeUndDecryptKeyWithCode_Roundtrip()
{
var syncKey = SyncCrypto.GenerateKey();
var wrapped = SyncCrypto.EncryptKeyWithCode(syncKey, "TIGER-42-BLAU");
Assert.Equal(syncKey, SyncCrypto.DecryptKeyWithCode(wrapped, "TIGER-42-BLAU"));
}
[Fact]
public void DecryptKeyWithCode_FalscherCode_WirftCryptographicException()
{
var syncKey = SyncCrypto.GenerateKey();
var wrapped = SyncCrypto.EncryptKeyWithCode(syncKey, "TIGER-42-BLAU");
Assert.ThrowsAny<CryptographicException>(() => SyncCrypto.DecryptKeyWithCode(wrapped, "FALSCH-99-CODE"));
}
// ── Selbstverwalteter Wiederherstellungscode (10.3.2) ────────────────────────
[Fact]
public void GenerateRecoveryCode_LiefertSechsGruppenZuJeVierZeichen()
{
var code = SyncCrypto.GenerateRecoveryCode();
var groups = code.Split('-');
Assert.Equal(6, groups.Length);
Assert.All(groups, g => Assert.Equal(4, g.Length));
// RFC 4648 Base32-Alphabet, keine Verwechslungsgefahr mit 0/1/8/9 o.ä.
Assert.All(code.Where(c => c != '-'), c => Assert.Contains(c, "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"));
}
[Fact]
public void GenerateRecoveryCode_LiefertJedesMalEinenAnderenCode()
{
var a = SyncCrypto.GenerateRecoveryCode();
var b = SyncCrypto.GenerateRecoveryCode();
Assert.NotEqual(a, b);
}
[Fact]
public void EncryptKeyWithRecoveryCodeUndDecryptKeyWithRecoveryCode_Roundtrip()
{
var syncKey = SyncCrypto.GenerateKey();
var code = SyncCrypto.GenerateRecoveryCode();
var wrapped = SyncCrypto.EncryptKeyWithRecoveryCode(syncKey, code);
Assert.Equal(syncKey, SyncCrypto.DecryptKeyWithRecoveryCode(wrapped, code));
}
/// Der Code wird von Hand abgetippt - Groß-/Kleinschreibung und ob die Trennstriche mitgetippt
/// wurden, dürfen die Wiederherstellung nicht scheitern lassen.
[Fact]
public void DecryptKeyWithRecoveryCode_ToleriertKleinschreibungUndFehlendeTrennstriche()
{
var syncKey = SyncCrypto.GenerateKey();
var code = SyncCrypto.GenerateRecoveryCode();
var wrapped = SyncCrypto.EncryptKeyWithRecoveryCode(syncKey, code);
var retyped = code.Replace("-", "").ToLowerInvariant();
Assert.Equal(syncKey, SyncCrypto.DecryptKeyWithRecoveryCode(wrapped, retyped));
}
[Fact]
public void DecryptKeyWithRecoveryCode_FalscherCode_WirftCryptographicException()
{
var syncKey = SyncCrypto.GenerateKey();
var wrapped = SyncCrypto.EncryptKeyWithRecoveryCode(syncKey, SyncCrypto.GenerateRecoveryCode());
Assert.ThrowsAny<CryptographicException>(
() => SyncCrypto.DecryptKeyWithRecoveryCode(wrapped, SyncCrypto.GenerateRecoveryCode()));
}
/// Pairing-Code und Wiederherstellungscode müssen unabhängige Ableitungen sein (eigenes Salt) -
/// sonst könnte ein kurzlebiger Pairing-Code auch den langlebigen Wiederherstellungsschutz
/// aushebeln.
[Fact]
public void DeriveKeyFromCodeUndDeriveRecoveryKeyFromCode_LiefernBeiGleichemCodeUnterschiedlicheSchluessel()
{
const string code = "GEMEINSAMER-CODE-123";
Assert.NotEqual(SyncCrypto.DeriveKeyFromCode(code), SyncCrypto.DeriveRecoveryKeyFromCode(code));
}
}
@@ -0,0 +1,61 @@
using LehrerApp.Sync.Crypto;
using Xunit;
namespace LehrerApp.Sync.Tests;
public sealed class SyncKeyRecoveryServiceTests
{
[Fact]
public void CreateRecoveryFileUndRestoreFromFile_Roundtrip()
{
using var temp = new TempKeyFile();
var originalKey = SyncCrypto.GenerateKey();
var service = new SyncKeyRecoveryService(originalKey, temp.KeyPath);
const string code = "TEST-CODE-4711";
var fileContent = service.CreateRecoveryFile(code);
// Ein zweites Gerät (anderer aktuell geladener Schlüssel) löst dieselbe Datei ein.
var redeemer = new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), temp.KeyPath);
redeemer.RestoreFromFile(fileContent, code);
Assert.Equal(originalKey, SyncCrypto.LoadKey(temp.KeyPath));
}
[Fact]
public void RestoreFromFile_FalscherCode_WirftUndSchreibtKeyPathNicht()
{
using var temp = new TempKeyFile();
var service = new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), temp.KeyPath);
var fileContent = service.CreateRecoveryFile("RICHTIGER-CODE");
Assert.ThrowsAny<Exception>(() => service.RestoreFromFile(fileContent, "FALSCHER-CODE"));
Assert.Null(SyncCrypto.LoadKey(temp.KeyPath));
}
[Fact]
public void RestoreFromFile_UngueltigesDateiformat_WirftInvalidOperationException()
{
using var temp = new TempKeyFile();
var service = new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), temp.KeyPath);
Assert.Throws<InvalidOperationException>(() => service.RestoreFromFile("{}", "IRGENDEIN-CODE"));
}
private sealed class TempKeyFile : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-synckeyrecovery-tests-{Guid.NewGuid():N}");
public string KeyPath { get; }
public TempKeyFile()
{
Directory.CreateDirectory(_directory);
KeyPath = Path.Combine(_directory, "sync.key");
}
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
+57
View File
@@ -44,6 +44,63 @@ public static class SyncCrypto
public static byte[] DecryptKeyWithCode(string encryptedB64, string code) => public static byte[] DecryptKeyWithCode(string encryptedB64, string code) =>
Decrypt(Convert.FromBase64String(encryptedB64), DeriveKeyFromCode(code)); Decrypt(Convert.FromBase64String(encryptedB64), DeriveKeyFromCode(code));
// ── Selbstverwalteter Wiederherstellungscode (10.3.2) ────────────────────────
//
// Anders als der Pairing-Code (kurzlebig, einmal serverseitig hinterlegt, siehe
// SnapshotService) verlässt dieser Weg den Client nie: der Nutzer sichert die verschlüsselte
// Schlüsseldatei selbst (z.B. USB-Stick, eigene Cloud) und merkt sich den Code getrennt davon
// — kein neuer dauerhafter Server-seitiger Speicher für ein sicherheitsrelevantes Geheimnis.
// Eigene Ableitung (eigenes Salt) statt Wiederverwendung von DeriveKeyFromCode, damit ein
// künftiger Wechsel am kurzlebigen Pairing-Schema diesen langlebigen Anwendungsfall nicht
// versehentlich mit beeinflusst (und umgekehrt).
private const string RecoveryCodeAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; // RFC 4648 Base32
/// <summary>120 Bit Zufall (15 Byte, exakt 24 Base32-Zeichen ohne Padding), in 4er-Gruppen
/// formatiert. Vom Programm zufällig erzeugt (kein Nutzer-Passwort) — 120 Bit sind auch ohne
/// PBKDF2-Härtung praktisch nicht erratbar; PBKDF2 dient hier vor allem der gleichförmigen
/// Schlüsselableitung, nicht dem Schutz vor schwachen Eingaben.</summary>
public static string GenerateRecoveryCode()
{
var bytes = new byte[15];
RandomNumberGenerator.Fill(bytes);
var raw = ToBase32(bytes);
return string.Join("-", Enumerable.Range(0, raw.Length / 4).Select(i => raw.Substring(i * 4, 4)));
}
public static byte[] DeriveRecoveryKeyFromCode(string code)
{
var normalized = new string(code.Where(c => !char.IsWhiteSpace(c) && c != '-').ToArray())
.ToUpperInvariant();
var bytes = Encoding.UTF8.GetBytes(normalized);
var salt = Encoding.UTF8.GetBytes("LehrerApp-RecoveryCode-v1");
return Rfc2898DeriveBytes.Pbkdf2(bytes, salt, Pbkdf2Iter, HashAlgorithmName.SHA256, 32);
}
public static string EncryptKeyWithRecoveryCode(byte[] syncKey, string code) =>
Convert.ToBase64String(Encrypt(syncKey, DeriveRecoveryKeyFromCode(code)));
public static byte[] DecryptKeyWithRecoveryCode(string encryptedB64, string code) =>
Decrypt(Convert.FromBase64String(encryptedB64), DeriveRecoveryKeyFromCode(code));
private static string ToBase32(byte[] data)
{
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
int bits = 0, value = 0;
foreach (var b in data)
{
value = (value << 8) | b;
bits += 8;
while (bits >= 5)
{
sb.Append(RecoveryCodeAlphabet[(value >> (bits - 5)) & 0x1F]);
bits -= 5;
}
}
if (bits > 0) sb.Append(RecoveryCodeAlphabet[(value << (5 - bits)) & 0x1F]);
return sb.ToString();
}
// ── Ver-/Entschlüsselung ─────────────────────────────────────────────────── // ── Ver-/Entschlüsselung ───────────────────────────────────────────────────
/// <summary>Format: [Nonce 12B][Ciphertext][Tag 16B]</summary> /// <summary>Format: [Nonce 12B][Ciphertext][Tag 16B]</summary>
+32
View File
@@ -0,0 +1,32 @@
using System.Text.Json;
using LehrerApp.Sync.Crypto;
namespace LehrerApp.Sync;
/// <summary>
/// Selbstverwalteter Wiederherstellungsweg für den lokalen Sync-Schlüssel (TODO 10.3.2). Anders
/// als das Geräte-Pairing (<see cref="SnapshotService"/>) läuft das komplett offline, ohne
/// Server-Beteiligung: der Nutzer sichert die verschlüsselte Schlüsseldatei selbst (z.B.
/// USB-Stick, eigene Cloud) und merkt sich den Code getrennt davon — Datei und Code zusammen
/// ergeben erst den Schlüssel.
/// </summary>
public class SyncKeyRecoveryService(byte[] syncKey, string keyPath)
{
public string CreateRecoveryFile(string code) =>
JsonSerializer.Serialize(new SyncKeyRecoveryFile(
SyncCrypto.EncryptKeyWithRecoveryCode(syncKey, code), DateTime.UtcNow));
/// <exception cref="System.Security.Cryptography.CryptographicException">
/// Falscher Code oder beschädigte/manipulierte Datei.</exception>
/// <exception cref="InvalidOperationException">Datei ist kein gültiges Wiederherstellungsformat.</exception>
public void RestoreFromFile(string fileContent, string code)
{
var file = JsonSerializer.Deserialize<SyncKeyRecoveryFile>(fileContent);
if (file is null || string.IsNullOrWhiteSpace(file.WrappedKey))
throw new InvalidOperationException("Ungültige Wiederherstellungsdatei.");
var restoredKey = SyncCrypto.DecryptKeyWithRecoveryCode(file.WrappedKey, code);
SyncCrypto.SaveKey(restoredKey, keyPath);
}
}
public record SyncKeyRecoveryFile(string WrappedKey, DateTime CreatedAt);
+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:** 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 diese Änderung betrifft `LehrerApp.Api` (Server) — ein reines Neubauen des Desktop-Clients
reicht nicht, der Server muss neu deployt werden. 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 — - [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 personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus
Notenwert/-kommentar) trugen bisher personenbezogene Daten im Klartext über den Notenwert/-kommentar) trugen bisher personenbezogene Daten im Klartext über den