Initial
This commit is contained in:
163
LehrerApp.Sync/Crypto/SyncCrypto.cs
Normal file
163
LehrerApp.Sync/Crypto/SyncCrypto.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Sync.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// AES-256-GCM Verschlüsselung für Sync-Payloads.
|
||||
/// Der Schlüssel verlässt nie den Client – der Server sieht nur Ciphertext.
|
||||
/// </summary>
|
||||
public 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
|
||||
|
||||
// PBKDF2-Parameter für Schlüsselableitung aus Einmal-Code
|
||||
private const int Pbkdf2Iterations = 100_000;
|
||||
private const int Pbkdf2KeySize = 32; // 256 bit
|
||||
|
||||
// ── Schlüsselverwaltung ───────────────────────────────────────────────────
|
||||
|
||||
public static byte[] GenerateKey()
|
||||
{
|
||||
var key = new byte[KeySize];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
public static string KeyToBase64(byte[] key) =>
|
||||
Convert.ToBase64String(key);
|
||||
|
||||
public static byte[] KeyFromBase64(string base64) =>
|
||||
Convert.FromBase64String(base64);
|
||||
|
||||
// ── Schlüssel aus Einmal-Code ableiten (PBKDF2) ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Leitet einen symmetrischen Schlüssel aus dem Einmal-Code ab.
|
||||
/// PBKDF2 macht Brute-Force bei kurzem Code teuer.
|
||||
///
|
||||
/// Salt ist fest definiert (kein Geheimnis, nur Domänentrennung).
|
||||
/// Der Code selbst ist das Geheimnis – 24h TTL schützt vor Angriffsfenstern.
|
||||
/// </summary>
|
||||
public static byte[] DeriveKeyFromCode(string code)
|
||||
{
|
||||
// Normalisieren: Groß, Leerzeichen entfernen
|
||||
var normalized = code.Trim().ToUpperInvariant();
|
||||
var codeBytes = Encoding.UTF8.GetBytes(normalized);
|
||||
|
||||
// Salt: App-spezifisch, öffentlich bekannt – verhindert
|
||||
// Rainbow-Tables gegen andere Anwendungen
|
||||
var salt = Encoding.UTF8.GetBytes("LehrerApp-PairingCode-v1");
|
||||
|
||||
return Rfc2898DeriveBytes.Pbkdf2(
|
||||
codeBytes,
|
||||
salt,
|
||||
Pbkdf2Iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
Pbkdf2KeySize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verschlüsselt den Sync-Schlüssel mit dem Code-Key.
|
||||
/// Ergebnis wird als Base64 auf dem Server hinterlegt.
|
||||
/// </summary>
|
||||
public static string EncryptKeyWithCode(byte[] syncKey, string code)
|
||||
{
|
||||
var codeKey = DeriveKeyFromCode(code);
|
||||
var encrypted = Encrypt(syncKey, codeKey);
|
||||
return Convert.ToBase64String(encrypted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entschlüsselt den Sync-Schlüssel mit dem Code.
|
||||
/// Wirft CryptographicException wenn der Code falsch ist.
|
||||
/// </summary>
|
||||
public static byte[] DecryptKeyWithCode(string encryptedKeyBase64, string code)
|
||||
{
|
||||
var codeKey = DeriveKeyFromCode(code);
|
||||
var encrypted = Convert.FromBase64String(encryptedKeyBase64);
|
||||
return Decrypt(encrypted, codeKey);
|
||||
}
|
||||
|
||||
// ── Ver-/Entschlüsselung ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verschlüsselt mit AES-256-GCM.
|
||||
/// Format: [Nonce 12 Bytes][Ciphertext][Auth-Tag 16 Bytes]
|
||||
/// </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 verschlüsseltes Format.");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Komfort-Methoden für JSON-Payloads ────────────────────────────────────
|
||||
|
||||
public static string EncryptObject<T>(T obj, byte[] key)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(obj);
|
||||
var encrypted = Encrypt(json, key);
|
||||
return Convert.ToBase64String(encrypted);
|
||||
}
|
||||
|
||||
public static T? DecryptObject<T>(string base64, byte[] key)
|
||||
{
|
||||
var encrypted = Convert.FromBase64String(base64);
|
||||
var json = Decrypt(encrypted, key);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
|
||||
// ── Schlüsselspeicherung ──────────────────────────────────────────────────
|
||||
|
||||
public static void SaveKey(byte[] key, string keyFilePath)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(keyFilePath);
|
||||
if (dir != null) Directory.CreateDirectory(dir);
|
||||
|
||||
File.WriteAllText(keyFilePath, KeyToBase64(key));
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(keyFilePath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? LoadKey(string keyFilePath)
|
||||
{
|
||||
if (!File.Exists(keyFilePath)) return null;
|
||||
return KeyFromBase64(File.ReadAllText(keyFilePath).Trim());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user