Files
LehrerApp/LehrerApp.Sync/SnapshotService.cs
T
adminandClaude Sonnet 5 dc21cb2319 fix: Geräte-Pairing - Schlüssel-Code stimmte nie mit dem angezeigten Code überein
SnapshotService.CreateAndUploadAsync lädt in zwei Schritten hoch: Schritt 1
holt einen Code vom Server, Schritt 2 verschlüsselt den Sync-Schlüssel mit
diesem Code und lädt erneut hoch. SnapshotStore.Store() vergab bei jedem
Aufruf bedingungslos einen neuen Zufallscode - der dem Nutzer am Ende
angezeigte Code war dadurch nie derselbe, mit dem der Schlüssel tatsächlich
verschlüsselt wurde. Jede Kopplung musste deterministisch an der
Schlüssel-Entschlüsselung scheitern.

SnapshotUploadRequest bekommt ein optionales Code-Feld; Store() aktualisiert
bei vorhandenem, passendem Code denselben Eintrag statt einen neuen mit
neuem Code anzulegen. Betrifft LehrerApp.Api - der Server muss neu deployt
werden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 21:34:43 +02:00

102 lines
4.9 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>
/// Device-Pairing via verschlüsseltem Snapshot + Einmal-Code.
///
/// Sender: CreateAndUploadAsync() → zeigt Code dem Nutzer
/// Empfänger: RestoreFromCodeAsync(code) → entschlüsselt Schlüssel + DB
/// </summary>
public class SnapshotService(
HttpClient http, LiteDbContext db, byte[] syncKey,
DeviceType deviceType, string dbPath, string keyPath)
{
public event Action<SnapshotProgress>? ProgressChanged;
public async Task<SnapshotUploadResponse> CreateAndUploadAsync(CancellationToken ct = default)
{
Report(SnapshotStep.Checkpointing, "Datenbank wird gesichert…");
db.Checkpoint();
Report(SnapshotStep.Reading, "Datenbank wird gelesen…");
var dbBytes = await File.ReadAllBytesAsync(dbPath, ct);
Report(SnapshotStep.Encrypting, "Verschlüsselung läuft…");
var encPayload = Convert.ToBase64String(SyncCrypto.Encrypt(dbBytes, syncKey));
// Schritt 1: Upload ohne Key → Code erhalten
Report(SnapshotStep.Uploading, "Code wird angefordert…");
var r1 = await http.PostAsJsonAsync("/api/snapshot/upload",
new SnapshotUploadRequest { EncryptedPayload = encPayload, DeviceType = deviceType }, ct);
r1.EnsureSuccessStatusCode();
var init = await r1.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
?? throw new InvalidOperationException("Leere Server-Antwort.");
// Schritt 2: Key mit Code verschlüsseln + erneut hochladen
Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt…");
var encKey = SyncCrypto.EncryptKeyWithCode(syncKey, init.Code);
var r2 = await http.PostAsJsonAsync("/api/snapshot/upload",
new SnapshotUploadRequest { EncryptedPayload = encPayload,
EncryptedSyncKey = encKey, DeviceType = deviceType,
Code = init.Code }, ct);
r2.EnsureSuccessStatusCode();
var result = await r2.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
?? throw new InvalidOperationException("Leere Server-Antwort.");
Report(SnapshotStep.Done, $"Bereit Code: {result.Code}");
return result;
}
public async Task<byte[]> RestoreFromCodeAsync(string code, string targetDbPath,
CancellationToken ct = default)
{
var sanitized = code.Trim().ToUpperInvariant();
Report(SnapshotStep.Downloading, "Snapshot wird geladen…");
var resp = await http.GetAsync($"/api/snapshot/{sanitized}", ct);
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
throw new SnapshotNotFoundException($"Code '{sanitized}' nicht gefunden oder abgelaufen.");
resp.EnsureSuccessStatusCode();
var dl = await resp.Content.ReadFromJsonAsync<SnapshotDownloadResponse>(ct)
?? throw new InvalidOperationException("Leere Server-Antwort.");
Report(SnapshotStep.Decrypting, "Schlüssel wird entschlüsselt…");
byte[] restoredKey;
try { restoredKey = SyncCrypto.DecryptKeyWithCode(dl.EncryptedSyncKey, sanitized); }
catch (System.Security.Cryptography.CryptographicException)
{ throw new InvalidOperationException("Schlüssel-Entschlüsselung fehlgeschlagen Code korrekt?"); }
SyncCrypto.SaveKey(restoredKey, keyPath);
Report(SnapshotStep.Decrypting, "Datenbank wird entschlüsselt…");
var dbBytes = SyncCrypto.Decrypt(Convert.FromBase64String(dl.EncryptedPayload), restoredKey);
Report(SnapshotStep.Writing, "Datenbank wird geschrieben…");
Directory.CreateDirectory(Path.GetDirectoryName(targetDbPath)!);
if (File.Exists(targetDbPath))
File.Move(targetDbPath, $"{targetDbPath}.backup-{DateTime.Now:yyyyMMdd-HHmmss}");
await File.WriteAllBytesAsync(targetDbPath, dbBytes, ct);
Report(SnapshotStep.Done, "Abgeschlossen bitte App neu starten.");
return restoredKey;
}
private void Report(SnapshotStep step, string msg) =>
ProgressChanged?.Invoke(new(step, msg));
}
public record SnapshotProgress(SnapshotStep Step, string Message)
{
public int Percent => 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 msg) : Exception(msg);