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
@@ -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
};
}