359 lines
12 KiB
C#
359 lines
12 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 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 <c>enc:v2</c> 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.
|
|
/// </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 { /* 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()
|
|
{
|
|
// 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<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 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<SecretProtectionException>(() => 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<SecretProtectionException>(() => 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<SecretProtectionException>(
|
|
() => 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<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 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<AgentConfig>(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<string, object?> { ["password"] = "" } }
|
|
};
|
|
|
|
ConfigSecrets.Protect(agent);
|
|
|
|
agent.Tools["Mail"]["password"].ShouldBe("");
|
|
}
|
|
}
|