This commit is contained in:
2026-03-29 23:47:31 +02:00
commit 216d5d2280
75 changed files with 5702 additions and 0 deletions

View 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);