S7: Zugangsdaten in Konfigurationsdateien verschluesseln
OpenRouter-Schluessel, Datenbank-Verbindungszeichenfolgen samt Passwort, Mail-Zugangsdaten und das Telegram-2FA-Passwort lagen im Klartext in AgentSettings.json und InstanceSettings.json. Wer die Dateien lesen konnte — ein Backup, eine Dateifreigabe, ein versehentlicher Commit — hatte alle Zugaenge. SecretProtector nutzt DPAPI im Benutzerkontext: Die Werte lassen sich nur vom selben Windows-Benutzer auf demselben Rechner lesen. Das schuetzt gegen Weitergabe der Datei, nicht gegen einen Angreifer, der bereits als dieser Benutzer laeuft — fuer einen lokal laufenden Dienst die angemessene Stufe. Verschluesselte Werte tragen ein Praefix. Dadurch bleibt Klartext aus bestehenden Konfigurationen lesbar und wird beim naechsten Speichern automatisch uebernommen; vorhandene Installationen laufen ohne Zutun weiter. Ein Wert, der sich nicht entschluesseln laesst — etwa nach Benutzer- oder Rechnerwechsel — wird gemeldet statt stillschweigend als Klartext durchgereicht. Sonst ginge ein unbrauchbarer Schluessel an die API und der Fehler waere schwer zuzuordnen. ConfigSecrets entscheidet anhand der Feldnamen, welche Werte betroffen sind. Das ist noetig, weil die Tool-Konfiguration ein freies Woerterbuch ist. Beim Speichern werden die Werte nur fuer den Schreibvorgang verschluesselt und danach wieder entschluesselt, damit die laufende Instanz weiterarbeiten kann. 309 Tests gruen (161 Core, 148 Tools). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
93f47aca3a
commit
8604fa30c7
@@ -0,0 +1,224 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Security;
|
||||
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.
|
||||
/// </summary>
|
||||
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<SecretProtectionException>(
|
||||
() => 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<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("");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user