Files
LehrerApp/LehrerApp.Sync/SnapshotService.cs
2026-03-29 23:47:31 +02:00

246 lines
9.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net.Http.Json;
using LehrerApp.Data;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// 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
/// </summary>
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<SnapshotProgress>? 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 ────────────────────────────────────────────────────────────────
/// <summary>
/// Erstellt Snapshot + verschlüsselten Schlüssel und lädt beides hoch.
/// Der zurückgegebene Code ist das einzige was der Empfänger braucht.
/// </summary>
public async Task<SnapshotUploadResponse> 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<SnapshotUploadResponse>(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<SnapshotUploadResponse>(ct)
?? throw new InvalidOperationException("Leere Server-Antwort.");
Report(SnapshotStep.Done, $"Bereit. Code: {result.Code}");
return result;
}
// ── Empfänger ─────────────────────────────────────────────────────────────
/// <summary>
/// Lädt Snapshot und Schlüssel anhand des Einmal-Codes.
/// Extrahiert den Sync-Schlüssel, speichert ihn lokal,
/// und baut die neue LiteDB auf.
/// </summary>
public async Task<byte[]> 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<SnapshotDownloadResponse>(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);