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 from the inventory: OpenRouter keys, database connection strings /// including password, mail credentials and the Telegram 2FA password were in plain text in /// the JSON files. Whoever could read the files had all access. /// /// With the Linux port enc:v2 was added (AES-GCM instead of DPAPI). The tests /// therefore run against a key of their own in a throwaway directory — otherwise /// they would create or read the real user key. /// [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 { /* Cleaning up is secondary */ } } [Fact] public void An_encrypted_value_can_be_read_again() { const string secret = "sk-or-v1-sehr-geheim-12345"; var protectedValue = SecretProtector.Protect(secret); SecretProtector.Unprotect(protectedValue).ShouldBe(secret); } [Fact] public void The_encrypted_value_does_not_contain_the_plaintext() { const string secret = "sk-or-v1-sehr-geheim-12345"; var protectedValue = SecretProtector.Protect(secret); protectedValue.ShouldNotBeNull().ShouldNotContain(secret); protectedValue.ShouldNotBe(secret); } [Fact] public void Encrypted_values_are_recognisable_as_such() { SecretProtector.IsProtected(SecretProtector.Protect("geheim")).ShouldBeTrue(); SecretProtector.IsProtected("klartext").ShouldBeFalse(); } [Fact] public void Encrypting_repeatedly_changes_nothing() { // Important, because when saving it is not known whether a value was already // encrypted. var once = SecretProtector.Protect("geheim"); var twice = SecretProtector.Protect(once); twice.ShouldBe(once); SecretProtector.Unprotect(twice).ShouldBe("geheim"); } [Fact] public void Plaintext_from_old_configurations_is_read_unchanged() { // Two reasons, not just one: // // 1. Existing installations must keep running without intervention. // 2. The initial installation via the update-agent writes license key and // Deploymentcenter token as plain text into Settings.json — the installer // does not know our encryption. On the first save they are // taken over and encrypted. Whoever removes this branch because it looks like // legacy locks out every freshly installed instance. SecretProtector.Unprotect("sk-or-alter-klartext").ShouldBe("sk-or-alter-klartext"); } [Theory] [InlineData(null)] [InlineData("")] public void Empty_values_stay_empty(string? value) { SecretProtector.Protect(value).ShouldBe(value); SecretProtector.Unprotect(value).ShouldBe(value); } [Fact] public void A_damaged_value_is_reported_instead_of_silently_passed_on() { // After a change of user or machine the value is not readable here. Outputting it as // plain text would send an unusable key to the API. 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 Umlauts_and_special_characters_survive_the_operation() { const string secret = "P@ssw0rt mit Grüßen & Ümlauten 🦀"; SecretProtector.Unprotect(SecretProtector.Protect(secret)).ShouldBe(secret); } // ═══════════════════════════════════════════════════════════ // Cross-platform format (enc:v2) // ═══════════════════════════════════════════════════════════ [Fact] public void New_values_are_written_in_the_cross_platform_format() { // The core of the port: under Linux the previous version returned the plain text // here. On a server that gets backed up, that was worse than nothing. var value = SecretProtector.Protect("sk-or-v1-geheim"); value.ShouldStartWith("enc:v2:"); } [Fact] public void The_same_input_gives_two_different_outputs() { // AES-GCM with a random nonce: identical values must not become identical // blocks, otherwise the configuration file reveals where the same password // is used several times. 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 A_modified_block_is_recognised_and_not_decrypted() { // The purpose of the authentication in AES-GCM: a tampered block must // not yield half-guessed plain text. 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 A_foreign_key_cannot_read_the_value() { // Exactly the property that makes up the protection: the configuration file alone // is of no use on another machine, because the key does not travel along. var value = SecretProtector.Protect("sk-or-v1-geheim"); var foreign = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); try { SecretKeyStore.UseDirectory(foreign); Should.Throw(() => SecretProtector.Unprotect(value)); } finally { SecretKeyStore.UseDirectory(_keyDir); try { Directory.Delete(foreign, recursive: true); } catch { } } } [Fact] public void The_key_is_created_once_and_then_reused() { 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] stops the test under Windows, but the platform analyzer does not see /// that — it only knows attributes like SupportedOSPlatform. Without the /// annotation CA1416 remained as the only warning in the build, and a single /// warning that is always there soon nobody reads any more. /// [LinuxFact] [System.Runtime.Versioning.SupportedOSPlatform("linux")] public void The_key_file_is_readable_by_the_owner_only() { SecretProtector.Protect("geheim"); File.GetUnixFileMode(SecretKeyStore.KeyFilePath) .ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite); } [LinuxFact] public void A_DPAPI_value_from_a_Windows_instance_is_rejected_with_a_reason() { // The moving case. Passing it on as plain text would send an unusable // key to the API — the message must say what to do. var ex = Should.Throw( () => SecretProtector.Unprotect("enc:v1:" + Convert.ToBase64String([1, 2, 3, 4]))); ex.Message.ShouldNotBeNull().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 { /* Cleaning up is secondary */ } } [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 Only_known_secret_fields_are_handled(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 Secrets_of_an_agent_configuration_are_encrypted() { var agent = AgentWithSecrets(); ConfigSecrets.Protect(agent); agent.Tools["Database"]["connectionString"]!.ToString().ShouldNotBeNull().ShouldNotContain("geheim"); agent.Tools["Mail"]["password"]!.ToString().ShouldNotBeNull().ShouldNotContain("mail-passwort"); } [Fact] public void Credentials_a_unit_holds_are_protected_whatever_their_name() { // #692: everything a unit holds for its members is a secret. var instance = new InstanceConfig(); instance.Units.Add(new UnitConfig { Id = "purchasing", Name = "Purchasing", Credentials = new() { ["DirectAPI"] = new() { ["marketplaceKey"] = "mk-123" } } }); ConfigSecrets.Protect(instance); instance.Units[0].Credentials!["DirectAPI"]["marketplaceKey"].ShouldNotContain("mk-123"); ConfigSecrets.Unprotect(instance); instance.Units[0].Credentials!["DirectAPI"]["marketplaceKey"].ShouldBe("mk-123"); } [Fact] public void Non_secret_fields_stay_readable() { var agent = AgentWithSecrets(); ConfigSecrets.Protect(agent); agent.Tools["Database"]["type"].ShouldBe("mysql"); agent.Tools["Mail"]["smtpHost"].ShouldBe("smtp.example.com"); } [Fact] public void The_round_trip_restores_the_original_values() { 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 Values_from_deserialised_JSON_are_captured_too() { // After deserialization the values sit in the dictionary as JsonElement, // not as 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().ShouldNotBeNull().ShouldNotContain("geheim123"); ConfigSecrets.Unprotect(agent); agent.Tools["Mail"]["password"].ShouldBe("geheim123"); } [Fact] public void The_instance_key_and_Telegram_credentials_are_captured() { 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 A_configuration_without_Telegram_does_no_harm() { var config = new InstanceConfig { OpenRouterApiKey = "sk-test", TelegramClient = null }; Should.NotThrow(() => ConfigSecrets.Protect(config)); } [Fact] public void Empty_secret_fields_stay_empty() { var agent = new AgentConfig { AgentId = "test", Tools = { ["Mail"] = new Dictionary { ["password"] = "" } } }; ConfigSecrets.Protect(agent); agent.Tools["Mail"]["password"].ShouldBe(""); } }