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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user