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>
154 lines
7.2 KiB
C#
154 lines
7.2 KiB
C#
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;
|
||
}
|