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:
@@ -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<EventQueue>()));
|
||||
@@ -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<byte[]>(), keyPath));
|
||||
|
||||
var serverUrl = syncSettings.ServerUrl;
|
||||
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.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<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; } = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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