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,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);