using System.Security.Cryptography; using System.Text; namespace ClawdDotNet.Core.Security; /// /// Verschlüsselt Werte mit einer Passphrase statt mit DPAPI. /// /// Hintergrund: nutzt DPAPI im Benutzerkontext — /// entschlüsseln kann nur derselbe Windows-Benutzer auf demselben Rechner. Für den /// laufenden Betrieb ist das richtig, für ein Backup jedoch untauglich: Ein Backup /// wird gerade dann gebraucht, wenn der Rechner defekt ist. Die Zugangsdaten darin /// wären auf dem Ersatzrechner nicht lesbar. /// /// Deshalb werden Zugangsdaten beim Sichern auf eine Passphrase umgeschlüsselt und /// beim Wiederherstellen zurück auf DPAPI. /// /// Aufbau eines geschützten Werts: /// pbe:v1:<salt>:<nonce>:<tag>:<ciphertext> /// Alle Teile Base64. Jeder Wert bekommt ein eigenes Salz und einen eigenen Nonce — /// gleiche Klartexte ergeben dadurch unterschiedliche Chiffrate. /// public static class PassphraseProtector { private const string Prefix = "pbe:v1:"; private const int SaltBytes = 16; private const int NonceBytes = 12; // AES-GCM Standard private const int TagBytes = 16; private const int KeyBytes = 32; // AES-256 /// /// Rundenzahl der Schlüsselableitung. Hoch genug, um Rateversuche teuer zu machen, /// niedrig genug für ein Backup mit vielen Einzelwerten. /// private const int Iterations = 210_000; public static bool IsProtected(string? value) => value?.StartsWith(Prefix, StringComparison.Ordinal) == true; public static string? Protect(string? plainText, string passphrase) { if (string.IsNullOrEmpty(plainText)) return plainText; if (string.IsNullOrEmpty(passphrase)) throw new ArgumentException("Passphrase darf nicht leer sein.", nameof(passphrase)); var salt = RandomNumberGenerator.GetBytes(SaltBytes); var nonce = RandomNumberGenerator.GetBytes(NonceBytes); var key = DeriveKey(passphrase, salt); var plain = Encoding.UTF8.GetBytes(plainText); var cipher = new byte[plain.Length]; var tag = new byte[TagBytes]; using (var aes = new AesGcm(key, TagBytes)) aes.Encrypt(nonce, plain, cipher, tag); CryptographicOperations.ZeroMemory(key); return Prefix + Convert.ToBase64String(salt) + ":" + Convert.ToBase64String(nonce) + ":" + Convert.ToBase64String(tag) + ":" + Convert.ToBase64String(cipher); } public static string? Unprotect(string? value, string passphrase) { if (string.IsNullOrEmpty(value) || !IsProtected(value)) return value; var parts = value[Prefix.Length..].Split(':'); if (parts.Length != 4) throw new SecretProtectionException( "Der geschützte Wert ist unvollständig oder beschädigt.", new FormatException("Erwartet werden vier Abschnitte.")); byte[] salt, nonce, tag, cipher; try { salt = Convert.FromBase64String(parts[0]); nonce = Convert.FromBase64String(parts[1]); tag = Convert.FromBase64String(parts[2]); cipher = Convert.FromBase64String(parts[3]); } catch (FormatException ex) { throw new SecretProtectionException("Der geschützte Wert ist beschädigt.", ex); } var key = DeriveKey(passphrase, salt); var plain = new byte[cipher.Length]; try { using var aes = new AesGcm(key, TagBytes); aes.Decrypt(nonce, cipher, tag, plain); } catch (CryptographicException ex) { // AES-GCM erkennt sowohl eine falsche Passphrase als auch nachträgliche // Veränderung — beides landet hier. throw new SecretProtectionException( "Entschlüsselung fehlgeschlagen. Entweder ist die Passphrase falsch " + "oder die Sicherung wurde verändert.", ex); } finally { CryptographicOperations.ZeroMemory(key); } return Encoding.UTF8.GetString(plain); } private static byte[] DeriveKey(string passphrase, byte[] salt) => Rfc2898DeriveBytes.Pbkdf2( Encoding.UTF8.GetBytes(passphrase), salt, Iterations, HashAlgorithmName.SHA256, KeyBytes); }