using System.Net.Http.Json; using LehrerApp.Data; using LehrerApp.Sync.Crypto; using LehrerApp.Sync.Models; namespace LehrerApp.Sync; /// /// Verwaltet den initialen Snapshot-Austausch beim Einrichten eines neuen Geräts. /// /// Ablauf Sender (Desktop): /// 1. LiteDB checkpoint + lesen /// 2. Mit Sync-Schlüssel (AES-256) verschlüsseln → EncryptedPayload /// 3. Sync-Schlüssel mit Code-Key (PBKDF2 aus Einmal-Code) verschlüsseln → EncryptedSyncKey /// 4. Beides zum Server pushen → Einmal-Code erhalten /// 5. Code dem Nutzer anzeigen – das ist alles was nötig ist /// /// Ablauf Empfänger (neues Gerät): /// 1. Einmal-Code eingeben /// 2. EncryptedPayload + EncryptedSyncKey laden /// 3. Code → PBKDF2 → Code-Key → EncryptedSyncKey entschlüsseln → Sync-Schlüssel /// 4. Sync-Schlüssel lokal speichern (sync.key) /// 5. EncryptedPayload mit Sync-Schlüssel entschlüsseln → LiteDB /// 6. Normaler Event-Sync startet /// public class SnapshotService { private readonly HttpClient _http; private readonly LiteDbContext _db; private readonly byte[] _syncKey; private readonly DeviceType _deviceType; private readonly string _dbPath; private readonly string _keyPath; public event Action? ProgressChanged; public SnapshotService( HttpClient http, LiteDbContext db, byte[] syncKey, DeviceType deviceType, string dbPath, string keyPath) { _http = http; _db = db; _syncKey = syncKey; _deviceType = deviceType; _dbPath = dbPath; _keyPath = keyPath; } // ── Sender ──────────────────────────────────────────────────────────────── /// /// Erstellt Snapshot + verschlüsselten Schlüssel und lädt beides hoch. /// Der zurückgegebene Code ist das einzige was der Empfänger braucht. /// public async Task CreateAndUploadAsync( CancellationToken ct = default) { // 1. DB sauber schreiben Report(SnapshotStep.Checkpointing, "Datenbank wird gesichert…"); _db.Checkpoint(); // 2. DB-Bytes lesen Report(SnapshotStep.Reading, "Datenbank wird gelesen…"); var dbBytes = await File.ReadAllBytesAsync(_dbPath, ct); // 3. Snapshot mit Sync-Schlüssel verschlüsseln Report(SnapshotStep.Encrypting, "Datenbank wird verschlüsselt…"); var encryptedPayload = Convert.ToBase64String( SyncCrypto.Encrypt(dbBytes, _syncKey)); // 4. Hochladen – Server generiert den Einmal-Code // Der EncryptedSyncKey wird NACH dem Upload erzeugt, // weil wir dafür den Code brauchen den der Server zurückgibt. // Deshalb: zweistufiger Upload. Report(SnapshotStep.Uploading, "Erster Upload – Code wird angefordert…"); // Schritt 4a: Initialer Upload ohne EncryptedSyncKey → Code erhalten var initRequest = new SnapshotUploadRequest { EncryptedPayload = encryptedPayload, EncryptedSyncKey = "", // noch leer DeviceType = _deviceType, }; var initResponse = await _http.PostAsJsonAsync( "/api/snapshot/upload", initRequest, ct); initResponse.EnsureSuccessStatusCode(); var initResult = await initResponse.Content .ReadFromJsonAsync(ct) ?? throw new InvalidOperationException("Leere Server-Antwort."); // Schritt 4b: Sync-Schlüssel mit dem erhaltenen Code verschlüsseln Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt und ergänzt…"); var encryptedSyncKey = SyncCrypto.EncryptKeyWithCode( _syncKey, initResult.Code); // Schritt 4c: Vollständiger Upload mit EncryptedSyncKey var fullRequest = new SnapshotUploadRequest { EncryptedPayload = encryptedPayload, EncryptedSyncKey = encryptedSyncKey, DeviceType = _deviceType, }; var fullResponse = await _http.PostAsJsonAsync( "/api/snapshot/upload", fullRequest, ct); fullResponse.EnsureSuccessStatusCode(); var result = await fullResponse.Content .ReadFromJsonAsync(ct) ?? throw new InvalidOperationException("Leere Server-Antwort."); Report(SnapshotStep.Done, $"Bereit. Code: {result.Code}"); return result; } // ── Empfänger ───────────────────────────────────────────────────────────── /// /// Lädt Snapshot und Schlüssel anhand des Einmal-Codes. /// Extrahiert den Sync-Schlüssel, speichert ihn lokal, /// und baut die neue LiteDB auf. /// public async Task RestoreFromCodeAsync( string code, string targetDbPath, CancellationToken ct = default) { var sanitized = code.Trim().ToUpperInvariant(); // 1. Snapshot + verschlüsselten Schlüssel laden Report(SnapshotStep.Downloading, "Snapshot wird geladen…"); var response = await _http.GetAsync( $"/api/snapshot/{sanitized}", ct); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) throw new SnapshotNotFoundException( $"Kein Snapshot für Code '{sanitized}'. " + "Abgelaufen oder bereits verwendet."); response.EnsureSuccessStatusCode(); var download = await response.Content .ReadFromJsonAsync(ct) ?? throw new InvalidOperationException("Leere Server-Antwort."); // 2. Sync-Schlüssel aus dem Code extrahieren Report(SnapshotStep.Decrypting, "Schlüssel wird entschlüsselt…"); byte[] syncKey; try { syncKey = SyncCrypto.DecryptKeyWithCode( download.EncryptedSyncKey, sanitized); } catch (System.Security.Cryptography.CryptographicException) { throw new InvalidOperationException( "Schlüssel-Entschlüsselung fehlgeschlagen. " + "Ist der Code korrekt eingegeben?"); } // 3. Sync-Schlüssel lokal speichern – ab jetzt kann normal gesynct werden SyncCrypto.SaveKey(syncKey, _keyPath); // 4. LiteDB entschlüsseln Report(SnapshotStep.Decrypting, "Datenbank wird entschlüsselt…"); byte[] dbBytes; try { var encrypted = Convert.FromBase64String(download.EncryptedPayload); dbBytes = SyncCrypto.Decrypt(encrypted, syncKey); } catch (System.Security.Cryptography.CryptographicException) { // Sollte nicht passieren wenn der Schlüssel korrekt war throw new InvalidOperationException( "Datenbank-Entschlüsselung fehlgeschlagen. " + "Der Snapshot könnte beschädigt sein."); } // 5. LiteDB schreiben Report(SnapshotStep.Writing, "Datenbank wird geschrieben…"); var dir = Path.GetDirectoryName(targetDbPath); if (dir != null) Directory.CreateDirectory(dir); if (File.Exists(targetDbPath)) { var backup = targetDbPath + $".backup-{DateTime.Now:yyyyMMdd-HHmmss}"; File.Move(targetDbPath, backup); } await File.WriteAllBytesAsync(targetDbPath, dbBytes, ct); Report(SnapshotStep.Done, $"Wiederhergestellt vom {download.CreatedAt:dd.MM.yyyy HH:mm}. " + "Bitte App neu starten."); // Neuen Sync-Schlüssel zurückgeben – AppBootstrapper muss ihn neu laden return syncKey; } private void Report(SnapshotStep step, string message) => ProgressChanged?.Invoke(new SnapshotProgress(step, message)); } // ── Fortschritts-Modelle ────────────────────────────────────────────────────── public record SnapshotProgress(SnapshotStep Step, string Message) { public int PercentComplete => Step switch { SnapshotStep.Checkpointing => 10, SnapshotStep.Reading => 20, SnapshotStep.Encrypting => 35, SnapshotStep.Uploading => 60, SnapshotStep.Downloading => 35, SnapshotStep.Decrypting => 65, SnapshotStep.Writing => 85, SnapshotStep.Done => 100, _ => 0, }; public bool IsComplete => Step == SnapshotStep.Done; } public enum SnapshotStep { Idle, Checkpointing, Reading, Encrypting, Uploading, Downloading, Decrypting, Writing, Done, } public class SnapshotNotFoundException(string message) : Exception(message);