Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
391 lines
14 KiB
C#
391 lines
14 KiB
C#
using System.Text.Json;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Security;
|
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Security;
|
|
|
|
/// <summary>
|
|
/// 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 <c>enc:v2</c> 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.
|
|
/// </summary>
|
|
[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<SecretProtectionException>(
|
|
() => SecretProtector.Unprotect("enc:v1:das-ist-kein-gueltiger-block"));
|
|
|
|
Should.Throw<SecretProtectionException>(
|
|
() => 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<SecretProtectionException>(() => 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<SecretProtectionException>(() => 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>[LinuxFact]</c> stops the test under Windows, but the platform analyzer does not see
|
|
/// that — it only knows attributes like <c>SupportedOSPlatform</c>. 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.
|
|
/// </summary>
|
|
[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<SecretProtectionException>(
|
|
() => 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<string, object?>
|
|
{
|
|
["type"] = "mysql",
|
|
["connectionString"] = "Server=x;Database=y;User=z;Password=geheim;",
|
|
["allowedTables"] = "prices"
|
|
},
|
|
["Mail"] = new Dictionary<string, object?>
|
|
{
|
|
["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<AgentConfig>(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<string, object?> { ["password"] = "" } }
|
|
};
|
|
|
|
ConfigSecrets.Protect(agent);
|
|
|
|
agent.Tools["Mail"]["password"].ShouldBe("");
|
|
}
|
|
}
|