using System.Text.Json; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Tests.Infrastructure; 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. /// /// Mit der Linux-Portierung kam enc:v2 dazu (AES-GCM statt DPAPI). Die Tests /// laufen deshalb gegen einen eigenen Schlüssel in einem Wegwerfverzeichnis — sonst /// würden sie den echten Benutzerschlüssel anlegen oder lesen. /// [Collection(SecretKeyCollection.Name)] public sealed class SecretProtectorTests : IDisposable { private readonly string _keyDir; public SecretProtectorTests() { _keyDir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); SecretKeyStore.UseDirectory(_keyDir); } public void Dispose() { SecretKeyStore.UseDirectory(null); try { Directory.Delete(_keyDir, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } [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() { // Zwei Gründe, nicht nur einer: // // 1. Bestehende Installationen müssen ohne Zutun weiterlaufen. // 2. Die Erstinstallation über den update-agent schreibt Lizenzschlüssel und // Deploymentcenter-Token als Klartext in die Settings.json — der Installer // kennt unsere Verschlüsselung nicht. Beim ersten Speichern werden sie // übernommen und verschlüsselt. Wer diesen Zweig entfernt, weil er nach // Altlast aussieht, sperrt jede frisch installierte Instanz aus. 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")); Should.Throw( () => SecretProtector.Unprotect("enc:v2: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); } // ═══════════════════════════════════════════════════════════ // Plattformübergreifendes Format (enc:v2) // ═══════════════════════════════════════════════════════════ [Fact] public void Neue_Werte_werden_im_plattformuebergreifenden_Format_geschrieben() { // Der Kern der Portierung: Unter Linux gab die vorige Fassung hier den Klartext // zurück. Auf einem Server, der gesichert wird, war das schlechter als nichts. var value = SecretProtector.Protect("sk-or-v1-geheim"); value.ShouldStartWith("enc:v2:"); } [Fact] public void Dieselbe_Eingabe_ergibt_zweimal_verschiedene_Ausgaben() { // AES-GCM mit zufälligem Nonce: Aus gleichen Werten dürfen keine gleichen // Blöcke werden, sonst verrät die Konfigurationsdatei, wo dasselbe Passwort // mehrfach benutzt wird. var a = SecretProtector.Protect("dasselbe-passwort"); var b = SecretProtector.Protect("dasselbe-passwort"); a.ShouldNotBe(b); SecretProtector.Unprotect(a).ShouldBe("dasselbe-passwort"); SecretProtector.Unprotect(b).ShouldBe("dasselbe-passwort"); } [Fact] public void Ein_veraenderter_Block_wird_erkannt_und_nicht_entschluesselt() { // Der Zweck der Authentifizierung in AES-GCM: Ein manipulierter Block darf // keinen halb geratenen Klartext ergeben. var value = SecretProtector.Protect("sk-or-v1-geheim")!; var payload = Convert.FromBase64String(value["enc:v2:".Length..]); payload[^1] ^= 0xFF; var tampered = "enc:v2:" + Convert.ToBase64String(payload); Should.Throw(() => SecretProtector.Unprotect(tampered)); } [Fact] public void Ein_fremder_Schluessel_kann_den_Wert_nicht_lesen() { // Genau die Eigenschaft, die den Schutz ausmacht: Die Konfigurationsdatei allein // nützt auf einem anderen Rechner nichts, weil der Schlüssel nicht mitreist. var value = SecretProtector.Protect("sk-or-v1-geheim"); var fremd = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); try { SecretKeyStore.UseDirectory(fremd); Should.Throw(() => SecretProtector.Unprotect(value)); } finally { SecretKeyStore.UseDirectory(_keyDir); try { Directory.Delete(fremd, recursive: true); } catch { } } } [Fact] public void Der_Schluessel_wird_einmal_angelegt_und_dann_wiederverwendet() { var first = SecretProtector.Protect("geheim"); var keyBytes = File.ReadAllBytes(SecretKeyStore.KeyFilePath); var second = SecretProtector.Protect("noch-geheimer"); File.ReadAllBytes(SecretKeyStore.KeyFilePath).ShouldBe(keyBytes); SecretProtector.Unprotect(first).ShouldBe("geheim"); SecretProtector.Unprotect(second).ShouldBe("noch-geheimer"); } [LinuxFact] public void Die_Schluesseldatei_ist_nur_fuer_den_Besitzer_lesbar() { SecretProtector.Protect("geheim"); File.GetUnixFileMode(SecretKeyStore.KeyFilePath) .ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite); } [LinuxFact] public void Ein_DPAPI_Wert_aus_einer_Windows_Instanz_wird_mit_Begruendung_abgewiesen() { // Der Umzugsfall. Ihn als Klartext durchzureichen würde einen unbrauchbaren // Schlüssel an die API schicken — die Meldung muss sagen, was zu tun ist. var ex = Should.Throw( () => SecretProtector.Unprotect("enc:v1:" + Convert.ToBase64String([1, 2, 3, 4]))); ex.Message.ShouldContain("neu eingetragen"); } } [Collection(SecretKeyCollection.Name)] public sealed class ConfigSecretsTests : IDisposable { private readonly string _keyDir; public ConfigSecretsTests() { _keyDir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); SecretKeyStore.UseDirectory(_keyDir); } public void Dispose() { SecretKeyStore.UseDirectory(null); try { Directory.Delete(_keyDir, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } [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(""); } }