246 lines
9.0 KiB
C#
246 lines
9.0 KiB
C#
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);
|