diff --git a/Services/InstanceDirectoryManager.cs b/Services/InstanceDirectoryManager.cs index ab51992..14f6d2b 100644 --- a/Services/InstanceDirectoryManager.cs +++ b/Services/InstanceDirectoryManager.cs @@ -1,5 +1,6 @@ using System.Text.Json; using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Security; using ClawdDotNet.Models; namespace ClawdDotNet.Services; @@ -182,7 +183,18 @@ public sealed class InstanceDirectoryManager public void SaveInstanceConfig(string instanceDir, InstanceConfig config) { var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json"); - SaveJson(settingsPath, config); + + // Zugangsdaten nur für das Schreiben verschlüsseln — die laufende Instanz + // braucht sie danach wieder im Klartext. + ConfigSecrets.Protect(config); + try + { + SaveJson(settingsPath, config); + } + finally + { + ConfigSecrets.Unprotect(config); + } } // ═══════════════════════════════════════════════════ @@ -271,6 +283,7 @@ public sealed class InstanceDirectoryManager var json = File.ReadAllText(settingsPath); config = JsonSerializer.Deserialize(json, JsonOpts) ?? new AgentConfig(); ConfigLoader.Migrate(config); + ConfigSecrets.Unprotect(config); } else { @@ -310,7 +323,16 @@ public sealed class InstanceDirectoryManager public void SaveAgentSettings(string agentDir, AgentConfig config) { var settingsPath = Path.Combine(agentDir, "AgentSettings.json"); - SaveJson(settingsPath, config); + + ConfigSecrets.Protect(config); + try + { + SaveJson(settingsPath, config); + } + finally + { + ConfigSecrets.Unprotect(config); + } } public void SaveAgentIdentity(string agentDir, string identity) diff --git a/docs/Bestandsaufnahme-2026-07.md b/docs/Bestandsaufnahme-2026-07.md index d115ba0..ebc16eb 100644 --- a/docs/Bestandsaufnahme-2026-07.md +++ b/docs/Bestandsaufnahme-2026-07.md @@ -549,5 +549,27 @@ Siehe K3. 15. S4 PermissionGate ausbauen **Danach** -16. Restliche Bugs (B6–B10, B13), S4/S7, weitere Tools, Streaming - (~~S5~~ ✅ SSRF, ~~S6~~ ✅ Pfadprüfung sind behoben) +16. Restliche Bugs (B6–B10, B13), S4 PermissionGate, weitere Tools, Streaming + (~~S5~~ ✅ SSRF, ~~S6~~ ✅ Pfadprüfung, ~~S7~~ ✅ Secrets sind behoben) + +--- + +## 7. Umgesetzt + +| Punkt | Was | +|---|---| +| B1, B14 | Compaction: sicherer Schnittpunkt, kein doppelter System-Prompt | +| B2 | Chat-Läufe pro Agent serialisiert, `AbortChat` erreicht alle | +| B3 | `maxCumulativeTokens` von `maxContextTokens` getrennt | +| B4 | Prompt/Completion getrennt erfasst, Preise live vom Anbieter | +| B12 | Retry mit Backoff für 429/5xx | +| S1 | `SqlGuard` statt Teilzeichenketten-Prüfung | +| S2 | `YouTubeUrl` + `ArgumentList` gegen Options-Injection | +| S3 | `UrlSanitizer` gegen API-Key-Leak ins Modell | +| S5 | `UrlGuard`, Redirects einzeln geprüft | +| S6 | `WorkspacePath` auf Verzeichnisgrenzen | +| S7 | `SecretProtector` (DPAPI) für Zugangsdaten | +| T1, T9 | Prompt-Caching mit Breakpoints, `cached_tokens` gemessen | +| T2 | Tool-Ergebnisse zentral gekappt | +| T3 | Günstiges Modell für die Zusammenfassung | +| K3 | Testfundament: 309 Tests, davon ~90 gezielte Angriffsfälle | diff --git a/src/ClawdDotNet.Core/ClawdDotNet.Core.csproj b/src/ClawdDotNet.Core/ClawdDotNet.Core.csproj index a92ce0f..1c4b79c 100644 --- a/src/ClawdDotNet.Core/ClawdDotNet.Core.csproj +++ b/src/ClawdDotNet.Core/ClawdDotNet.Core.csproj @@ -13,6 +13,7 @@ + diff --git a/src/ClawdDotNet.Core/Config/ConfigLoader.cs b/src/ClawdDotNet.Core/Config/ConfigLoader.cs index b26a061..b47cf92 100644 --- a/src/ClawdDotNet.Core/Config/ConfigLoader.cs +++ b/src/ClawdDotNet.Core/Config/ConfigLoader.cs @@ -21,6 +21,9 @@ public static class ConfigLoader ?? throw new InvalidOperationException($"Config file is empty or invalid: {filePath}"); Migrate(config); + // Verschlüsselte Zugangsdaten für die Laufzeit lesbar machen. Klartext aus + // älteren Konfigurationen bleibt unverändert und wird beim Speichern übernommen. + Security.ConfigSecrets.Unprotect(config); Validate(config, filePath); return config; } diff --git a/src/ClawdDotNet.Core/Security/ConfigSecrets.cs b/src/ClawdDotNet.Core/Security/ConfigSecrets.cs new file mode 100644 index 0000000..d502d54 --- /dev/null +++ b/src/ClawdDotNet.Core/Security/ConfigSecrets.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; + +namespace ClawdDotNet.Core.Security; + +/// +/// Weiß, welche Felder einer Konfiguration Zugangsdaten enthalten, und wendet den +/// darauf an. +/// +/// Beide Richtungen sind gefahrlos mehrfach anwendbar: Schützen überspringt bereits +/// geschützte Werte, Entschlüsseln gibt Klartext unverändert zurück. Dadurch werden +/// bestehende Konfigurationen beim ersten Speichern automatisch übernommen. +/// +public static class ConfigSecrets +{ + /// + /// Schlüsselnamen in Tool-Konfigurationen, deren Werte als Zugangsdaten gelten. + /// Die Tool-Konfiguration ist ein freies Wörterbuch — deshalb wird hier nach + /// Namen entschieden. + /// + private static readonly HashSet SecretKeys = new(StringComparer.OrdinalIgnoreCase) + { + "password", "password2fa", "passwort", + "apikey", "api_key", "xapikey", "openrouterapikey", "apihash", "apisecret", + "token", "accesstoken", "bottoken", "authtoken", + "secret", "clientsecret", + "connectionstring" + }; + + public static bool IsSecretKey(string key) => SecretKeys.Contains(key); + + // ─── Instanz ─── + + public static void Protect(InstanceConfig config) => Apply(config, SecretProtector.Protect); + + public static void Unprotect(InstanceConfig config) => Apply(config, SecretProtector.Unprotect); + + private static void Apply(InstanceConfig config, Func transform) + { + config.OpenRouterApiKey = transform(config.OpenRouterApiKey) ?? ""; + + if (config.TelegramClient is { } telegram) + { + telegram.ApiHash = transform(telegram.ApiHash) ?? ""; + telegram.Password2FA = transform(telegram.Password2FA); + } + + foreach (var agent in config.Agents) + Apply(agent, transform); + } + + // ─── Agent ─── + + public static void Protect(AgentConfig agent) => Apply(agent, SecretProtector.Protect); + + public static void Unprotect(AgentConfig agent) => Apply(agent, SecretProtector.Unprotect); + + private static void Apply(AgentConfig agent, Func transform) + { + foreach (var tool in agent.Tools.Values) + { + foreach (var key in tool.Keys.ToList()) + { + if (!IsSecretKey(key)) + continue; + + if (ReadString(tool[key]) is not { } current || current.Length == 0) + continue; + + tool[key] = transform(current); + } + } + } + + /// + /// Liest einen Zeichenkettenwert. Nach dem Deserialisieren stecken die Werte als + /// JsonElement im Wörterbuch, nach einer Bearbeitung als einfache Zeichenkette. + /// + private static string? ReadString(object? value) => value switch + { + null => null, + string s => s, + JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(), + _ => null + }; +} diff --git a/src/ClawdDotNet.Core/Security/SecretProtector.cs b/src/ClawdDotNet.Core/Security/SecretProtector.cs new file mode 100644 index 0000000..89bdc34 --- /dev/null +++ b/src/ClawdDotNet.Core/Security/SecretProtector.cs @@ -0,0 +1,98 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; + +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. +/// +/// Verwendet wird DPAPI im Benutzerkontext: Die Daten lassen sich nur von demselben +/// Windows-Benutzer auf demselben Rechner entschlüsseln. Das schützt gegen Weitergabe +/// der Datei, nicht gegen einen Angreifer, der bereits als dieser Benutzer läuft — +/// für einen lokal laufenden Dienst ist das die angemessene Stufe. +/// +/// Verschlüsselte Werte tragen ein Präfix, damit Klartext aus älteren Konfigurationen +/// weiterhin gelesen und beim nächsten Speichern automatisch übernommen wird. +/// +public static class SecretProtector +{ + private const string Prefix = "enc:v1:"; + + /// Zusätzlicher Kontext, damit ein Wert nicht in anderem Zusammenhang wiederverwendbar ist. + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v1"); + + public static bool IsProtected(string? value) + => value?.StartsWith(Prefix, StringComparison.Ordinal) == true; + + /// + /// 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; + + if (!OperatingSystem.IsWindows()) + return plainText; + + try + { + var encrypted = ProtectWindows(Encoding.UTF8.GetBytes(plainText)); + return Prefix + Convert.ToBase64String(encrypted); + } + catch (CryptographicException) + { + // Lieber unverschlüsselt weiterarbeiten als die Konfiguration verlieren. + return plainText; + } + } + + /// + /// 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) || !IsProtected(value)) + return value; + + if (!OperatingSystem.IsWindows()) + return value; + + var payload = value[Prefix.Length..]; + + try + { + var decrypted = UnprotectWindows(Convert.FromBase64String(payload)); + return Encoding.UTF8.GetString(decrypted); + } + catch (Exception ex) when (ex is CryptographicException or FormatException) + { + // Etwa nach Benutzerwechsel oder Rechnerwechsel: Der Wert ist hier nicht + // lesbar. Ihn als Klartext auszugeben wäre falsch — dann würde ein + // unbrauchbarer Schlüssel an die API gehen. + 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[] ProtectWindows(byte[] data) + => ProtectedData.Protect(data, Entropy, DataProtectionScope.CurrentUser); + + [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); diff --git a/tests/ClawdDotNet.Core.Tests/Security/SecretProtectorTests.cs b/tests/ClawdDotNet.Core.Tests/Security/SecretProtectorTests.cs new file mode 100644 index 0000000..0f75d70 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Security/SecretProtectorTests.cs @@ -0,0 +1,224 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Security; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Security; + +/// +/// S7 aus der Bestandsaufnahme: OpenRouter-Schlüssel, Datenbank-Verbindungszeichenfolgen +/// samt Passwort, Mail-Zugangsdaten und das Telegram-2FA-Passwort lagen im Klartext in +/// den JSON-Dateien. Wer die Dateien lesen konnte, hatte alle Zugänge. +/// +public sealed class SecretProtectorTests +{ + [Fact] + public void Ein_verschluesselter_Wert_laesst_sich_wieder_lesen() + { + const string secret = "sk-or-v1-sehr-geheim-12345"; + + var protectedValue = SecretProtector.Protect(secret); + + SecretProtector.Unprotect(protectedValue).ShouldBe(secret); + } + + [Fact] + public void Der_verschluesselte_Wert_enthaelt_den_Klartext_nicht() + { + const string secret = "sk-or-v1-sehr-geheim-12345"; + + var protectedValue = SecretProtector.Protect(secret); + + protectedValue.ShouldNotContain(secret); + protectedValue.ShouldNotBe(secret); + } + + [Fact] + public void Verschluesselte_Werte_sind_als_solche_erkennbar() + { + SecretProtector.IsProtected(SecretProtector.Protect("geheim")).ShouldBeTrue(); + SecretProtector.IsProtected("klartext").ShouldBeFalse(); + } + + [Fact] + public void Mehrfaches_Verschluesseln_veraendert_nichts() + { + // Wichtig, weil beim Speichern nicht bekannt ist, ob ein Wert schon + // verschlüsselt war. + var once = SecretProtector.Protect("geheim"); + var twice = SecretProtector.Protect(once); + + twice.ShouldBe(once); + SecretProtector.Unprotect(twice).ShouldBe("geheim"); + } + + [Fact] + public void Klartext_aus_alten_Konfigurationen_wird_unveraendert_gelesen() + { + // Bestehende Installationen müssen ohne Zutun weiterlaufen. + SecretProtector.Unprotect("sk-or-alter-klartext").ShouldBe("sk-or-alter-klartext"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Leere_Werte_bleiben_leer(string? value) + { + SecretProtector.Protect(value).ShouldBe(value); + SecretProtector.Unprotect(value).ShouldBe(value); + } + + [Fact] + public void Ein_beschaedigter_Wert_wird_gemeldet_statt_stillschweigend_durchgereicht() + { + // Nach Benutzer- oder Rechnerwechsel ist der Wert hier nicht lesbar. Ihn als + // Klartext auszugeben würde einen unbrauchbaren Schlüssel an die API schicken. + Should.Throw( + () => SecretProtector.Unprotect("enc:v1:das-ist-kein-gueltiger-block")); + } + + [Fact] + public void Umlaute_und_Sonderzeichen_ueberstehen_den_Vorgang() + { + const string secret = "P@ssw0rt mit Grüßen & Ümlauten 🦀"; + + SecretProtector.Unprotect(SecretProtector.Protect(secret)).ShouldBe(secret); + } +} + +public sealed class ConfigSecretsTests +{ + [Theory] + [InlineData("password", true)] + [InlineData("apiKey", true)] + [InlineData("xApiKey", true)] + [InlineData("connectionString", true)] + [InlineData("password2FA", true)] + [InlineData("APIKEY", true)] + [InlineData("allowedTables", false)] + [InlineData("smtpHost", false)] + [InlineData("rootPath", false)] + public void Nur_bekannte_Geheimnisfelder_werden_behandelt(string key, bool expected) + { + ConfigSecrets.IsSecretKey(key).ShouldBe(expected); + } + + private static AgentConfig AgentWithSecrets() => new() + { + AgentId = "test", + Tools = + { + ["Database"] = new Dictionary + { + ["type"] = "mysql", + ["connectionString"] = "Server=x;Database=y;User=z;Password=geheim;", + ["allowedTables"] = "prices" + }, + ["Mail"] = new Dictionary + { + ["smtpHost"] = "smtp.example.com", + ["password"] = "mail-passwort" + } + } + }; + + [Fact] + public void Geheimnisse_einer_Agentenkonfiguration_werden_verschluesselt() + { + var agent = AgentWithSecrets(); + + ConfigSecrets.Protect(agent); + + agent.Tools["Database"]["connectionString"]!.ToString().ShouldNotContain("geheim"); + agent.Tools["Mail"]["password"]!.ToString().ShouldNotContain("mail-passwort"); + } + + [Fact] + public void Nicht_geheime_Felder_bleiben_lesbar() + { + var agent = AgentWithSecrets(); + + ConfigSecrets.Protect(agent); + + agent.Tools["Database"]["type"].ShouldBe("mysql"); + agent.Tools["Mail"]["smtpHost"].ShouldBe("smtp.example.com"); + } + + [Fact] + public void Der_Rundlauf_stellt_die_Originalwerte_wieder_her() + { + var agent = AgentWithSecrets(); + + ConfigSecrets.Protect(agent); + ConfigSecrets.Unprotect(agent); + + agent.Tools["Database"]["connectionString"] + .ShouldBe("Server=x;Database=y;User=z;Password=geheim;"); + agent.Tools["Mail"]["password"].ShouldBe("mail-passwort"); + } + + [Fact] + public void Werte_aus_deserialisiertem_JSON_werden_ebenfalls_erfasst() + { + // Nach dem Deserialisieren stecken die Werte als JsonElement im Wörterbuch, + // nicht als String. + var json = """ + { "agentId": "test", + "tools": { "Mail": { "smtpHost": "smtp.example.com", "password": "geheim123" } } } + """; + var agent = JsonSerializer.Deserialize(json)!; + + ConfigSecrets.Protect(agent); + + agent.Tools["Mail"]["password"]!.ToString().ShouldNotContain("geheim123"); + ConfigSecrets.Unprotect(agent); + agent.Tools["Mail"]["password"].ShouldBe("geheim123"); + } + + [Fact] + public void Der_Instanzschluessel_und_Telegram_Zugangsdaten_werden_erfasst() + { + var config = new InstanceConfig + { + OpenRouterApiKey = "sk-or-v1-geheim", + TelegramClient = new TelegramClientConfig + { + ApiHash = "hash-geheim", + Password2FA = "2fa-geheim" + } + }; + + ConfigSecrets.Protect(config); + + config.OpenRouterApiKey.ShouldNotContain("sk-or-v1-geheim"); + config.TelegramClient.ApiHash.ShouldNotContain("hash-geheim"); + config.TelegramClient.Password2FA.ShouldNotContain("2fa-geheim"); + + ConfigSecrets.Unprotect(config); + + config.OpenRouterApiKey.ShouldBe("sk-or-v1-geheim"); + config.TelegramClient.Password2FA.ShouldBe("2fa-geheim"); + } + + [Fact] + public void Eine_Konfiguration_ohne_Telegram_stoert_nicht() + { + var config = new InstanceConfig { OpenRouterApiKey = "sk-test", TelegramClient = null }; + + Should.NotThrow(() => ConfigSecrets.Protect(config)); + } + + [Fact] + public void Leere_Geheimnisfelder_bleiben_leer() + { + var agent = new AgentConfig + { + AgentId = "test", + Tools = { ["Mail"] = new Dictionary { ["password"] = "" } } + }; + + ConfigSecrets.Protect(agent); + + agent.Tools["Mail"]["password"].ShouldBe(""); + } +}