init 1.0.0

This commit is contained in:
2026-06-19 00:42:00 +02:00
commit 5ca960746b
67 changed files with 3261 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// Desktop gewinnt gegen Companion.
/// Bei Gleichstand: späterer Timestamp gewinnt.
/// </summary>
public class ConflictResolver(EventQueue queue)
{
public ConflictEntry? TryResolve(SyncEvent remote, string localDeviceId)
{
var local = queue.GetPending()
.FirstOrDefault(e => e.EntityType == remote.EntityType
&& e.EntityId == remote.EntityId
&& e.DeviceId != remote.DeviceId);
if (local is null) return null;
var winner = (local.DeviceType, remote.DeviceType) switch
{
(DeviceType.Desktop, DeviceType.Companion) => local,
(DeviceType.Companion, DeviceType.Desktop) => remote,
_ => local.Timestamp >= remote.Timestamp ? local : remote,
};
if (winner == remote) queue.Acknowledge([local.EventId]);
return new ConflictEntry
{
LocalEvent = local,
RemoteEvent = remote,
Resolution = winner == local ? "LocalWon" : "RemoteWon",
};
}
}
+96
View File
@@ -0,0 +1,96 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace LehrerApp.Sync.Crypto;
/// <summary>
/// AES-256-GCM Verschlüsselung + PBKDF2 Schlüsselableitung.
///
/// Pairing-Flow:
/// Sender: EncryptedSyncKey = AES(syncKey, PBKDF2(code))
/// Empfänger: syncKey = AES_Decrypt(EncryptedSyncKey, PBKDF2(code))
/// → Schlüssel verlässt nie den Server, nur der Code wird geteilt.
/// </summary>
public static class SyncCrypto
{
private const int KeySize = 32; // 256 bit
private const int NonceSize = 12; // 96 bit GCM Standard
private const int TagSize = 16; // 128 bit Auth-Tag
private const int Pbkdf2Iter = 100_000;
// ── Schlüssel ──────────────────────────────────────────────────────────────
public static byte[] GenerateKey()
{
var k = new byte[KeySize];
RandomNumberGenerator.Fill(k);
return k;
}
public static string KeyToBase64(byte[] key) => Convert.ToBase64String(key);
public static byte[] KeyFromBase64(string b64) => Convert.FromBase64String(b64);
/// <summary>Leitet Schlüssel aus Einmal-Code ab. PBKDF2 erschwert Brute-Force.</summary>
public static byte[] DeriveKeyFromCode(string code)
{
var bytes = Encoding.UTF8.GetBytes(code.Trim().ToUpperInvariant());
var salt = Encoding.UTF8.GetBytes("LehrerApp-PairingCode-v1");
return Rfc2898DeriveBytes.Pbkdf2(bytes, salt, Pbkdf2Iter, HashAlgorithmName.SHA256, 32);
}
public static string EncryptKeyWithCode(byte[] syncKey, string code) =>
Convert.ToBase64String(Encrypt(syncKey, DeriveKeyFromCode(code)));
public static byte[] DecryptKeyWithCode(string encryptedB64, string code) =>
Decrypt(Convert.FromBase64String(encryptedB64), DeriveKeyFromCode(code));
// ── Ver-/Entschlüsselung ───────────────────────────────────────────────────
/// <summary>Format: [Nonce 12B][Ciphertext][Tag 16B]</summary>
public static byte[] Encrypt(byte[] plaintext, byte[] key)
{
var nonce = new byte[NonceSize];
RandomNumberGenerator.Fill(nonce);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[TagSize];
using var aes = new AesGcm(key, TagSize);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
var result = new byte[NonceSize + ciphertext.Length + TagSize];
nonce.CopyTo(result, 0);
ciphertext.CopyTo(result, NonceSize);
tag.CopyTo(result, NonceSize + ciphertext.Length);
return result;
}
public static byte[] Decrypt(byte[] encrypted, byte[] key)
{
if (encrypted.Length < NonceSize + TagSize)
throw new CryptographicException("Ungültiges Datenformat.");
var nonce = encrypted[..NonceSize];
var tag = encrypted[^TagSize..];
var ciphertext = encrypted[NonceSize..^TagSize];
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, TagSize);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return plaintext;
}
// ── JSON-Komfort ───────────────────────────────────────────────────────────
public static string EncryptObject<T>(T obj, byte[] key) =>
Convert.ToBase64String(Encrypt(JsonSerializer.SerializeToUtf8Bytes(obj), key));
public static T? DecryptObject<T>(string b64, byte[] key) =>
JsonSerializer.Deserialize<T>(Decrypt(Convert.FromBase64String(b64), key));
// ── Schlüsselspeicherung ───────────────────────────────────────────────────
public static void SaveKey(byte[] key, string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, KeyToBase64(key));
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
public static byte[]? LoadKey(string path) =>
File.Exists(path) ? KeyFromBase64(File.ReadAllText(path).Trim()) : null;
}
+77
View File
@@ -0,0 +1,77 @@
using LiteDB;
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// Lokale Event-Queue in LiteDB. Puffert Events bis sie
/// erfolgreich zum Server gepusht wurden.
/// </summary>
public class EventQueue : IDisposable
{
private readonly LiteDatabase _db;
private readonly ILiteCollection<SyncEvent> _queue;
private readonly ILiteCollection<SyncMeta> _meta;
private readonly ILiteCollection<ConflictEntry> _conflicts;
private long _currentSeq;
public EventQueue(string path)
{
_db = new LiteDatabase(path);
_queue = _db.GetCollection<SyncEvent>("queue");
_meta = _db.GetCollection<SyncMeta>("meta");
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
_queue.EnsureIndex(x => x.SequenceNr);
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
}
public SyncEvent Enqueue(string deviceId, DeviceType deviceType,
string entityType, string entityId, string operation, string payload)
{
var evt = new SyncEvent
{
DeviceId = deviceId,
DeviceType = deviceType,
Timestamp = DateTime.UtcNow,
SequenceNr = ++_currentSeq,
EntityType = entityType,
EntityId = entityId,
Operation = operation,
Payload = payload,
};
_queue.Insert(evt);
_meta.Upsert(new SyncMeta { Id = "seq", Value = _currentSeq });
return evt;
}
public List<SyncEvent> GetPending(int max = 200) =>
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).Take(max).ToList();
public int PendingCount() => _queue.Count();
public void Acknowledge(IEnumerable<Guid> ids) { foreach (var id in ids) _queue.Delete(id); }
public long GetLastServerSeq() => _meta.FindById("serverSeq")?.Value ?? 0;
public void SetLastServerSeq(long nr) => _meta.Upsert(new SyncMeta { Id = "serverSeq", Value = nr });
public DateTime? GetLastSyncAt() => _meta.FindById("lastSync")?.Timestamp;
public void SetLastSyncAt(DateTime dt) =>
_meta.Upsert(new SyncMeta { Id = "lastSync", Value = 0, Timestamp = dt });
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
public void Dispose() => _db.Dispose();
}
public class ConflictEntry
{
public Guid Id { get; init; } = Guid.NewGuid();
public DateTime DetectedAt { get; init; } = DateTime.UtcNow;
public SyncEvent LocalEvent { get; init; } = null!;
public SyncEvent RemoteEvent { get; init; } = null!;
public string Resolution { get; init; } = "";
public bool Reviewed { get; set; }
}
internal class SyncMeta
{
public string Id { get; set; } = "";
public long Value { get; set; }
public DateTime? Timestamp { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="LiteDB" />
</ItemGroup>
</Project>
+95
View File
@@ -0,0 +1,95 @@
namespace LehrerApp.Sync.Models;
// ── Events ────────────────────────────────────────────────────────────────────
/// <summary>Desktop-Event: Payload ist AES-256-GCM verschlüsselt.</summary>
public class SyncEvent
{
public Guid EventId { get; init; } = Guid.NewGuid();
public string DeviceId { get; init; } = "";
public DeviceType DeviceType { get; init; }
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
public long SequenceNr { get; init; }
public string EntityType { get; init; } = "";
public string EntityId { get; init; } = "";
public string Operation { get; init; } = "";
/// <summary>Verschlüsselt (Desktop) oder Klartext (Companion/WebApp).</summary>
public string Payload { get; init; } = "";
}
/// <summary>WebApp/Companion-Event: Payload ist Klartext-JSON.</summary>
public class PlainSyncEvent
{
public Guid EventId { get; init; } = Guid.NewGuid();
public string DeviceId { get; init; } = "";
public DeviceType DeviceType { get; init; } = DeviceType.Companion;
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
public string EntityType { get; init; } = "";
public string EntityId { get; init; } = "";
public string Operation { get; init; } = "";
public string Payload { get; init; } = "";
}
// ── Sync API Responses ────────────────────────────────────────────────────────
public class PushResponse
{
public bool Success { get; init; }
public long ServerSequenceNr { get; init; }
public List<Guid> ConflictingEventIds { get; init; } = [];
}
public class PullResponse
{
public List<SyncEvent> Events { get; init; } = [];
public long ServerSequenceNr { get; init; }
}
public class PlainPushResponse
{
public bool Success { get; init; }
public long ServerSequenceNr { get; init; }
public List<Guid> RejectedEventIds { get; init; } = [];
}
// ── Snapshot Modelle ──────────────────────────────────────────────────────────
/// <summary>
/// Upload-Request für Device-Pairing.
/// EncryptedPayload: LiteDB verschlüsselt mit Sync-Key.
/// EncryptedSyncKey: Sync-Key verschlüsselt mit PBKDF2(Code).
/// → Empfänger braucht nur den Code um beides zu entschlüsseln.
/// </summary>
public class SnapshotUploadRequest
{
public string EncryptedPayload { get; init; } = "";
public string EncryptedSyncKey { get; init; } = "";
public DeviceType DeviceType { get; init; }
}
public class SnapshotUploadResponse
{
/// <summary>Format: WORT-ZZ-WORT, z.B. "TIGER-42-BLAU". 24h gültig, einmalig.</summary>
public string Code { get; init; } = "";
public DateTime ExpiresAt { get; init; }
}
public class SnapshotDownloadResponse
{
public string EncryptedPayload { get; init; } = "";
public string EncryptedSyncKey { get; init; } = "";
public DateTime CreatedAt { get; init; }
public DeviceType SourceDeviceType { get; init; }
}
// ── Status ────────────────────────────────────────────────────────────────────
public class SyncStatus
{
public SyncState State { get; set; } = SyncState.Idle;
public DateTime? LastSyncAt { get; set; }
public int PendingEvents { get; set; }
public int ConflictCount { get; set; }
public string? ErrorMessage { get; set; }
}
// ── Enums ─────────────────────────────────────────────────────────────────────
public enum DeviceType { Desktop, Companion }
public enum SyncState { Idle, Syncing, Error, Offline }
+100
View File
@@ -0,0 +1,100 @@
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 }, 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);
+116
View File
@@ -0,0 +1,116 @@
using System.Net.Http.Json;
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// Push/Pull Orchestrierung.
/// Automatisch alle N Minuten + manuell per SyncNowAsync().
/// </summary>
public class SyncEngine : IDisposable
{
private readonly EventQueue _queue;
private readonly ConflictResolver _resolver;
private readonly HttpClient _http;
private readonly SyncConfig _config;
private readonly Timer _timer;
public SyncStatus Status { get; private set; } = new();
public event Action<SyncStatus>? StatusChanged;
public SyncEngine(EventQueue queue, ConflictResolver resolver,
HttpClient http, SyncConfig config)
{
_queue = queue;
_resolver = resolver;
_http = http;
_config = config;
_timer = new Timer(
async _ => await SyncNowAsync(true), null,
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes));
UpdateStatus();
}
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
{
if (Status.State == SyncState.Syncing)
return new() { Skipped = true, Reason = "Sync bereits aktiv" };
SetState(SyncState.Syncing);
try
{
var (pushed, _) = await PushAsync();
var (pulled, conflicts) = await PullAsync();
_queue.SetLastSyncAt(DateTime.UtcNow);
SetState(SyncState.Idle);
return new() { Success = true, EventsPushed = pushed, EventsPulled = pulled, Conflicts = conflicts };
}
catch (HttpRequestException) { SetState(SyncState.Offline); return new() { Reason = "Server nicht erreichbar" }; }
catch (Exception ex) { SetState(SyncState.Error, ex.Message); return new() { Reason = ex.Message }; }
}
private async Task<(int Pushed, int Conflicts)> PushAsync()
{
var pending = _queue.GetPending();
if (pending.Count == 0) return (0, 0);
var resp = await _http.PostAsJsonAsync("/api/sync/push", pending);
resp.EnsureSuccessStatusCode();
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
if (result is null) return (0, 0);
_queue.Acknowledge(pending
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
.Select(e => e.EventId));
_queue.SetLastServerSeq(result.ServerSequenceNr);
return (pending.Count - result.ConflictingEventIds.Count,
result.ConflictingEventIds.Count);
}
private async Task<(int Pulled, int Conflicts)> PullAsync()
{
var since = _queue.GetLastServerSeq();
var resp = await _http.GetFromJsonAsync<PullResponse>(
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
if (resp is null || resp.Events.Count == 0) return (0, 0);
var conflicts = 0;
foreach (var evt in resp.Events)
{
var c = _resolver.TryResolve(evt, _config.DeviceId);
if (c is not null) { _queue.AddConflict(c); conflicts++; }
}
_queue.SetLastServerSeq(resp.ServerSequenceNr);
return (resp.Events.Count, conflicts);
}
private void SetState(SyncState state, string? error = null)
{
Status = new SyncStatus
{
State = state,
LastSyncAt = _queue.GetLastSyncAt(),
PendingEvents = _queue.PendingCount(),
ConflictCount = _queue.ConflictCount(),
ErrorMessage = error,
};
StatusChanged?.Invoke(Status);
}
private void UpdateStatus() => SetState(Status.State);
public void Dispose() { _timer.Dispose(); _queue.Dispose(); }
}
public class SyncConfig
{
public string ServerUrl { get; set; } = "";
public string DeviceId { get; set; } = "";
public DeviceType DeviceType { get; set; } = DeviceType.Desktop;
public int AutoSyncIntervalMinutes { get; set; } = 5;
}
public class SyncResult
{
public bool Success { get; set; }
public bool Skipped { get; set; }
public string? Reason { get; set; }
public int EventsPushed { get; set; }
public int EventsPulled { get; set; }
public int Conflicts { get; set; }
}