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:
@@ -737,6 +737,15 @@
|
||||
<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."/>
|
||||
|
||||
<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">
|
||||
<TextBlock Text="Server-Adresse" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding SyncServerUrl}" PlaceholderText="https://sync.example.com"/>
|
||||
@@ -786,6 +795,36 @@
|
||||
IsVisible="{Binding SyncForceResyncStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</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}">
|
||||
<Separator/>
|
||||
<TextBlock Text="Gerät koppeln" FontSize="14" FontWeight="SemiBold"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user