using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using ClawdDotNet.Core.Storage; namespace ClawdDotNet.Core.Security; /// /// Verschlüsselt Zugangsdaten in Konfigurationsdateien. /// /// Hintergrund (S7): OpenRouter-Schlüssel, Datenbank-Verbindungszeichenfolgen samt /// Passwort, Mail-Zugangsdaten und das Telegram-2FA-Passwort lagen im Klartext in /// AgentSettings.json und InstanceConfig.json. Wer die Dateien lesen konnte — ein /// Backup, eine Dateifreigabe, ein versehentlicher Commit — hatte alle Zugänge. /// /// Zwei Formate. /// /// /// enc:v1: — DPAPI im Benutzerkontext. Nur unter Windows lesbar. Wird /// nicht mehr geschrieben, aber weiterhin gelesen: bestehende Installationen sollen /// ohne Zutun weiterlaufen und wandern beim nächsten Speichern von selbst auf v2. /// enc:v2: — AES-256-GCM mit einem Schlüssel aus /// . Läuft auf jeder Plattform. /// /// /// Warum v2 überhaupt nötig wurde. Die vorige Fassung gab unter Linux /// stillschweigend den Klartext zurück — Protect verschlüsselte dort schlicht /// nicht. Auf einem Server, der per SSH erreichbar ist und gesichert wird, wäre das /// schlechter gewesen als auf einem Einzelplatz-Windows. /// /// Schutzstufe. Dieselbe wie DPAPI zuvor: gegen Weitergabe der /// Konfigurationsdatei, gegen ein Backup, gegen einen versehentlichen Commit — nicht /// gegen einen Angreifer, der bereits als dieser Benutzer läuft. Der Schlüssel liegt /// deshalb bewusst außerhalb des Instanzverzeichnisses: Eine Sicherung der /// Instanz enthält ihn nicht, und ob Geheimnisse mitreisen, entscheidet weiterhin /// allein die Sicherungsrichtlinie in BackupService. /// public static class SecretProtector { private const string PrefixV1 = "enc:v1:"; private const string PrefixV2 = "enc:v2:"; /// Zusätzlicher Kontext, damit ein Wert nicht in anderem Zusammenhang wiederverwendbar ist. private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v1"); /// Wird v2 als Zusatzangabe mitgeschrieben und beim Entschlüsseln geprüft. private static readonly byte[] AssociatedData = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v2"); private const int NonceSize = 12; // AES-GCM: vorgeschriebene Länge private const int TagSize = 16; public static bool IsProtected(string? value) => value is not null && (value.StartsWith(PrefixV2, StringComparison.Ordinal) || value.StartsWith(PrefixV1, StringComparison.Ordinal)); /// /// Verschlüsselt einen Wert. Bereits verschlüsselte und leere Werte bleiben unverändert, /// damit die Funktion gefahrlos mehrfach angewendet werden kann. /// public static string? Protect(string? plainText) { if (string.IsNullOrEmpty(plainText) || IsProtected(plainText)) return plainText; try { var key = SecretKeyStore.GetOrCreateKey(); var nonce = RandomNumberGenerator.GetBytes(NonceSize); var plain = Encoding.UTF8.GetBytes(plainText); var cipher = new byte[plain.Length]; var tag = new byte[TagSize]; using (var aes = new AesGcm(key, TagSize)) aes.Encrypt(nonce, plain, cipher, tag, AssociatedData); // nonce ‖ tag ‖ ciphertext — feste Längen vorn, damit das Zerlegen eindeutig ist. var payload = new byte[NonceSize + TagSize + cipher.Length]; nonce.CopyTo(payload, 0); tag.CopyTo(payload, NonceSize); cipher.CopyTo(payload, NonceSize + TagSize); return PrefixV2 + Convert.ToBase64String(payload); } catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException) { // Kein Schlüssel anlegbar (etwa ein schreibgeschütztes Konfigurationsverzeichnis). // Den Wert unverschlüsselt zu speichern wäre die stille Rückkehr zu genau dem // Zustand, den S7 behoben hat — deshalb hier abbrechen statt weiterreichen. throw new SecretProtectionException( "Ein Wert konnte nicht verschlüsselt werden, weil der lokale Schlüssel nicht " + $"lesbar oder anlegbar ist ({SecretKeyStore.KeyFilePath}). Ohne ihn würden " + "Zugangsdaten im Klartext gespeichert — der Vorgang wurde abgebrochen.", ex); } } /// /// Entschlüsselt einen Wert. Klartext aus älteren Konfigurationen wird unverändert /// zurückgegeben — so bleiben bestehende Installationen lauffähig. /// public static string? Unprotect(string? value) { if (string.IsNullOrEmpty(value)) return value; if (value.StartsWith(PrefixV2, StringComparison.Ordinal)) return UnprotectV2(value[PrefixV2.Length..]); if (value.StartsWith(PrefixV1, StringComparison.Ordinal)) return UnprotectV1(value[PrefixV1.Length..]); return value; // Klartext aus älteren Konfigurationen } private static string UnprotectV2(string payload) { try { var raw = Convert.FromBase64String(payload); if (raw.Length < NonceSize + TagSize) throw new CryptographicException("Der verschlüsselte Block ist unvollständig."); var key = SecretKeyStore.GetOrCreateKey(); var nonce = raw.AsSpan(0, NonceSize); var tag = raw.AsSpan(NonceSize, TagSize); var cipher = raw.AsSpan(NonceSize + TagSize); var plain = new byte[cipher.Length]; using (var aes = new AesGcm(key, TagSize)) aes.Decrypt(nonce, cipher, tag, plain, AssociatedData); return Encoding.UTF8.GetString(plain); } catch (Exception ex) when (ex is CryptographicException or FormatException or IOException or UnauthorizedAccessException) { throw new SecretProtectionException( "Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die " + "Konfiguration von einem anderen Rechner oder Benutzer stammt — der Schlüssel " + $"dazu liegt in {SecretKeyStore.KeyFilePath} und reist nicht mit. " + "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex); } } private static string UnprotectV1(string payload) { if (!OperatingSystem.IsWindows()) { // Der Fall beim Umzug einer Windows-Instanz auf Linux. Ihn als Klartext // durchzureichen wäre falsch — dann ginge ein unbrauchbarer Schlüssel an die API. throw new SecretProtectionException( "Dieser Wert wurde mit der Windows-Verschlüsselung (DPAPI) gesichert und lässt " + "sich hier nicht lesen. Beim Umzug einer Instanz von Windows müssen die " + "betroffenen Werte einmal neu eingetragen werden; danach liegen sie im " + "plattformübergreifenden Format vor.", new PlatformNotSupportedException("DPAPI ist nur unter Windows verfügbar.")); } try { return Encoding.UTF8.GetString(UnprotectWindows(Convert.FromBase64String(payload))); } catch (Exception ex) when (ex is CryptographicException or FormatException) { throw new SecretProtectionException( "Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die " + "Konfiguration von einem anderen Windows-Benutzer oder Rechner stammt. " + "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex); } } [SupportedOSPlatform("windows")] private static byte[] UnprotectWindows(byte[] data) => ProtectedData.Unprotect(data, Entropy, DataProtectionScope.CurrentUser); } public sealed class SecretProtectionException(string message, Exception inner) : Exception(message, inner);