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
@@ -22,9 +22,56 @@ public partial class SettingsView : UserControl
vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog;
vm.OnConfirmPairingRestore = ShowPairingRestoreConfirmDialog;
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)
{
var info = new ConfirmDialogInfo