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);
}