Initial
This commit is contained in:
245
LehrerApp.Sync/SnapshotService.cs
Normal file
245
LehrerApp.Sync/SnapshotService.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user