Files
LehrerApp/LehrerApp.Sync/Crypto/SyncCrypto.cs
T
adminandClaude Sonnet 5 4cce84c7dd feat: Warnung + selbstverwalteter Wiederherstellungscode für verlorenen Sync-Schlüssel
Der Sync-Schlüssel verlässt nie den Server im Klartext - verliert das einzige Gerät sync.key,
erzeugte AppBootstrapper bisher stillschweigend einen neuen, unabhängigen Schlüssel, ohne dass
der Nutzer etwas davon merkt. Bereits synchronisierte Server-Daten werden dadurch dauerhaft
unlesbar.

AppBootstrapper erkennt jetzt, ob ein fehlender Schlüssel bei einem bereits eingeloggten Gerät
auftritt (SyncKeyStatus) - SettingsViewModel zeigt dafür ein Warnbanner im Synchronisation-Tab mit
der Empfehlung, im Zweifel über ein noch intaktes zweites Gerät neu zu koppeln.

Zusätzlich ein komplett offline laufender, selbstverwalteter Wiederherstellungscode (kein neuer
dauerhafter Server-Speicher für ein sicherheitsrelevantes Geheimnis): SyncCrypto bekommt einen
neuen, vom Pairing-Code kryptografisch unabhängigen Recovery-Code (120 Bit, Base32), mit dem der
Sync-Schlüssel lokal verschlüsselt und als Datei durch den Nutzer selbst gesichert werden kann.
Neuer SyncKeyRecoveryService kapselt Erzeugen/Einlösen; Redeem schreibt den wiederhergestellten
Schlüssel und setzt vorsorglich Pull-Cursor/Versionsverfolgung zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:30:55 +02:00

154 lines
7.2 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.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));
// ── Selbstverwalteter Wiederherstellungscode (10.3.2) ────────────────────────
//
// Anders als der Pairing-Code (kurzlebig, einmal serverseitig hinterlegt, siehe
// SnapshotService) verlässt dieser Weg den Client nie: der Nutzer sichert die verschlüsselte
// Schlüsseldatei selbst (z.B. USB-Stick, eigene Cloud) und merkt sich den Code getrennt davon
// — kein neuer dauerhafter Server-seitiger Speicher für ein sicherheitsrelevantes Geheimnis.
// Eigene Ableitung (eigenes Salt) statt Wiederverwendung von DeriveKeyFromCode, damit ein
// künftiger Wechsel am kurzlebigen Pairing-Schema diesen langlebigen Anwendungsfall nicht
// versehentlich mit beeinflusst (und umgekehrt).
private const string RecoveryCodeAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; // RFC 4648 Base32
/// <summary>120 Bit Zufall (15 Byte, exakt 24 Base32-Zeichen ohne Padding), in 4er-Gruppen
/// formatiert. Vom Programm zufällig erzeugt (kein Nutzer-Passwort) — 120 Bit sind auch ohne
/// PBKDF2-Härtung praktisch nicht erratbar; PBKDF2 dient hier vor allem der gleichförmigen
/// Schlüsselableitung, nicht dem Schutz vor schwachen Eingaben.</summary>
public static string GenerateRecoveryCode()
{
var bytes = new byte[15];
RandomNumberGenerator.Fill(bytes);
var raw = ToBase32(bytes);
return string.Join("-", Enumerable.Range(0, raw.Length / 4).Select(i => raw.Substring(i * 4, 4)));
}
public static byte[] DeriveRecoveryKeyFromCode(string code)
{
var normalized = new string(code.Where(c => !char.IsWhiteSpace(c) && c != '-').ToArray())
.ToUpperInvariant();
var bytes = Encoding.UTF8.GetBytes(normalized);
var salt = Encoding.UTF8.GetBytes("LehrerApp-RecoveryCode-v1");
return Rfc2898DeriveBytes.Pbkdf2(bytes, salt, Pbkdf2Iter, HashAlgorithmName.SHA256, 32);
}
public static string EncryptKeyWithRecoveryCode(byte[] syncKey, string code) =>
Convert.ToBase64String(Encrypt(syncKey, DeriveRecoveryKeyFromCode(code)));
public static byte[] DecryptKeyWithRecoveryCode(string encryptedB64, string code) =>
Decrypt(Convert.FromBase64String(encryptedB64), DeriveRecoveryKeyFromCode(code));
private static string ToBase32(byte[] data)
{
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
int bits = 0, value = 0;
foreach (var b in data)
{
value = (value << 8) | b;
bits += 8;
while (bits >= 5)
{
sb.Append(RecoveryCodeAlphabet[(value >> (bits - 5)) & 0x1F]);
bits -= 5;
}
}
if (bits > 0) sb.Append(RecoveryCodeAlphabet[(value << (5 - bits)) & 0x1F]);
return sb.ToString();
}
// ── 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;
}