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:
Richard
2026-07-28 10:21:08 +02:00
co-authored by Claude Opus 4.8
parent 93f47aca3a
commit 8604fa30c7
7 changed files with 460 additions and 4 deletions
+22
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Models;
namespace ClawdDotNet.Services;
@@ -182,8 +183,19 @@ public sealed class InstanceDirectoryManager
public void SaveInstanceConfig(string instanceDir, InstanceConfig config)
{
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
// Zugangsdaten nur für das Schreiben verschlüsseln — die laufende Instanz
// braucht sie danach wieder im Klartext.
ConfigSecrets.Protect(config);
try
{
SaveJson(settingsPath, config);
}
finally
{
ConfigSecrets.Unprotect(config);
}
}
// ═══════════════════════════════════════════════════
// AGENTEN-OPERATIONEN
@@ -271,6 +283,7 @@ public sealed class InstanceDirectoryManager
var json = File.ReadAllText(settingsPath);
config = JsonSerializer.Deserialize<AgentConfig>(json, JsonOpts) ?? new AgentConfig();
ConfigLoader.Migrate(config);
ConfigSecrets.Unprotect(config);
}
else
{
@@ -310,8 +323,17 @@ public sealed class InstanceDirectoryManager
public void SaveAgentSettings(string agentDir, AgentConfig config)
{
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
ConfigSecrets.Protect(config);
try
{
SaveJson(settingsPath, config);
}
finally
{
ConfigSecrets.Unprotect(config);
}
}
public void SaveAgentIdentity(string agentDir, string identity)
{
+24 -2
View File
@@ -549,5 +549,27 @@ Siehe K3.
15. S4 PermissionGate ausbauen
**Danach**
16. Restliche Bugs (B6B10, B13), S4/S7, weitere Tools, Streaming
(~~S5~~ ✅ SSRF, ~~S6~~ ✅ Pfadprüfung sind behoben)
16. Restliche Bugs (B6B10, B13), S4 PermissionGate, weitere Tools, Streaming
(~~S5~~ ✅ SSRF, ~~S6~~ ✅ Pfadprüfung, ~~S7~~ ✅ Secrets sind behoben)
---
## 7. Umgesetzt
| Punkt | Was |
|---|---|
| B1, B14 | Compaction: sicherer Schnittpunkt, kein doppelter System-Prompt |
| B2 | Chat-Läufe pro Agent serialisiert, `AbortChat` erreicht alle |
| B3 | `maxCumulativeTokens` von `maxContextTokens` getrennt |
| B4 | Prompt/Completion getrennt erfasst, Preise live vom Anbieter |
| B12 | Retry mit Backoff für 429/5xx |
| S1 | `SqlGuard` statt Teilzeichenketten-Prüfung |
| S2 | `YouTubeUrl` + `ArgumentList` gegen Options-Injection |
| S3 | `UrlSanitizer` gegen API-Key-Leak ins Modell |
| S5 | `UrlGuard`, Redirects einzeln geprüft |
| S6 | `WorkspacePath` auf Verzeichnisgrenzen |
| S7 | `SecretProtector` (DPAPI) für Zugangsdaten |
| T1, T9 | Prompt-Caching mit Breakpoints, `cached_tokens` gemessen |
| T2 | Tool-Ergebnisse zentral gekappt |
| T3 | Günstiges Modell für die Zusammenfassung |
| K3 | Testfundament: 309 Tests, davon ~90 gezielte Angriffsfälle |
@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
</ItemGroup>
@@ -21,6 +21,9 @@ public static class ConfigLoader
?? throw new InvalidOperationException($"Config file is empty or invalid: {filePath}");
Migrate(config);
// Verschlüsselte Zugangsdaten für die Laufzeit lesbar machen. Klartext aus
// älteren Konfigurationen bleibt unverändert und wird beim Speichern übernommen.
Security.ConfigSecrets.Unprotect(config);
Validate(config, filePath);
return config;
}
@@ -0,0 +1,86 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Core.Security;
/// <summary>
/// Weiß, welche Felder einer Konfiguration Zugangsdaten enthalten, und wendet den
/// <see cref="SecretProtector"/> darauf an.
///
/// Beide Richtungen sind gefahrlos mehrfach anwendbar: Schützen überspringt bereits
/// geschützte Werte, Entschlüsseln gibt Klartext unverändert zurück. Dadurch werden
/// bestehende Konfigurationen beim ersten Speichern automatisch übernommen.
/// </summary>
public static class ConfigSecrets
{
/// <summary>
/// Schlüsselnamen in Tool-Konfigurationen, deren Werte als Zugangsdaten gelten.
/// Die Tool-Konfiguration ist ein freies Wörterbuch — deshalb wird hier nach
/// Namen entschieden.
/// </summary>
private static readonly HashSet<string> SecretKeys = new(StringComparer.OrdinalIgnoreCase)
{
"password", "password2fa", "passwort",
"apikey", "api_key", "xapikey", "openrouterapikey", "apihash", "apisecret",
"token", "accesstoken", "bottoken", "authtoken",
"secret", "clientsecret",
"connectionstring"
};
public static bool IsSecretKey(string key) => SecretKeys.Contains(key);
// ─── Instanz ───
public static void Protect(InstanceConfig config) => Apply(config, SecretProtector.Protect);
public static void Unprotect(InstanceConfig config) => Apply(config, SecretProtector.Unprotect);
private static void Apply(InstanceConfig config, Func<string?, string?> transform)
{
config.OpenRouterApiKey = transform(config.OpenRouterApiKey) ?? "";
if (config.TelegramClient is { } telegram)
{
telegram.ApiHash = transform(telegram.ApiHash) ?? "";
telegram.Password2FA = transform(telegram.Password2FA);
}
foreach (var agent in config.Agents)
Apply(agent, transform);
}
// ─── Agent ───
public static void Protect(AgentConfig agent) => Apply(agent, SecretProtector.Protect);
public static void Unprotect(AgentConfig agent) => Apply(agent, SecretProtector.Unprotect);
private static void Apply(AgentConfig agent, Func<string?, string?> transform)
{
foreach (var tool in agent.Tools.Values)
{
foreach (var key in tool.Keys.ToList())
{
if (!IsSecretKey(key))
continue;
if (ReadString(tool[key]) is not { } current || current.Length == 0)
continue;
tool[key] = transform(current);
}
}
}
/// <summary>
/// Liest einen Zeichenkettenwert. Nach dem Deserialisieren stecken die Werte als
/// JsonElement im Wörterbuch, nach einer Bearbeitung als einfache Zeichenkette.
/// </summary>
private static string? ReadString(object? value) => value switch
{
null => null,
string s => s,
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(),
_ => null
};
}
@@ -0,0 +1,98 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace ClawdDotNet.Core.Security;
/// <summary>
/// Verschlüsselt Zugangsdaten in Konfigurationsdateien.
///
/// Hintergrund (S7): OpenRouter-Schlüssel, Datenbank-Verbindungszeichenfolgen samt
/// Passwort, Mail-Zugangsdaten und das Telegram-2FA-Passwort lagen im Klartext in
/// AgentSettings.json und InstanceConfig.json. Wer die Dateien lesen konnte — ein
/// Backup, eine Dateifreigabe, ein versehentlicher Commit — hatte alle Zugänge.
///
/// Verwendet wird DPAPI im Benutzerkontext: Die Daten lassen sich nur von demselben
/// Windows-Benutzer auf demselben Rechner entschlüsseln. Das schützt gegen Weitergabe
/// der Datei, nicht gegen einen Angreifer, der bereits als dieser Benutzer läuft —
/// für einen lokal laufenden Dienst ist das die angemessene Stufe.
///
/// Verschlüsselte Werte tragen ein Präfix, damit Klartext aus älteren Konfigurationen
/// weiterhin gelesen und beim nächsten Speichern automatisch übernommen wird.
/// </summary>
public static class SecretProtector
{
private const string Prefix = "enc:v1:";
/// <summary>Zusätzlicher Kontext, damit ein Wert nicht in anderem Zusammenhang wiederverwendbar ist.</summary>
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v1");
public static bool IsProtected(string? value)
=> value?.StartsWith(Prefix, StringComparison.Ordinal) == true;
/// <summary>
/// Verschlüsselt einen Wert. Bereits verschlüsselte und leere Werte bleiben unverändert,
/// damit die Funktion gefahrlos mehrfach angewendet werden kann.
/// </summary>
public static string? Protect(string? plainText)
{
if (string.IsNullOrEmpty(plainText) || IsProtected(plainText))
return plainText;
if (!OperatingSystem.IsWindows())
return plainText;
try
{
var encrypted = ProtectWindows(Encoding.UTF8.GetBytes(plainText));
return Prefix + Convert.ToBase64String(encrypted);
}
catch (CryptographicException)
{
// Lieber unverschlüsselt weiterarbeiten als die Konfiguration verlieren.
return plainText;
}
}
/// <summary>
/// Entschlüsselt einen Wert. Klartext aus älteren Konfigurationen wird unverändert
/// zurückgegeben — so bleiben bestehende Installationen lauffähig.
/// </summary>
public static string? Unprotect(string? value)
{
if (string.IsNullOrEmpty(value) || !IsProtected(value))
return value;
if (!OperatingSystem.IsWindows())
return value;
var payload = value[Prefix.Length..];
try
{
var decrypted = UnprotectWindows(Convert.FromBase64String(payload));
return Encoding.UTF8.GetString(decrypted);
}
catch (Exception ex) when (ex is CryptographicException or FormatException)
{
// Etwa nach Benutzerwechsel oder Rechnerwechsel: Der Wert ist hier nicht
// lesbar. Ihn als Klartext auszugeben wäre falsch — dann würde ein
// unbrauchbarer Schlüssel an die API gehen.
throw new SecretProtectionException(
"Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die " +
"Konfiguration von einem anderen Windows-Benutzer oder Rechner stammt. " +
"Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
}
}
[SupportedOSPlatform("windows")]
private static byte[] ProtectWindows(byte[] data)
=> ProtectedData.Protect(data, Entropy, DataProtectionScope.CurrentUser);
[SupportedOSPlatform("windows")]
private static byte[] UnprotectWindows(byte[] data)
=> ProtectedData.Unprotect(data, Entropy, DataProtectionScope.CurrentUser);
}
public sealed class SecretProtectionException(string message, Exception inner)
: Exception(message, inner);
@@ -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("");
}
}