Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Security/SecretProtectorTests.cs
T
RichardandClaude Opus 5 33d95a6c3f UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4
Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem
Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag.

Oberflaeche
- Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als
  mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml
  (eigene ControlThemes statt Fluent umzufaerben).
- Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer
  Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite
  statt eigenem Fenster, Texteditor-Fenster.
- ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter
  entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt.

Rocket.Chat
- Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der
  Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird
  nur, wenn wirklich etwas anliegt.
- send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum
  ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist.
- Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei
  Annahmen waren falsch und sind korrigiert.

Deploymentcenter
- DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json)
  erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke.
- AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst
  ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:16:05 +02:00

366 lines
13 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()
{
// Zwei Gründe, nicht nur einer:
//
// 1. Bestehende Installationen müssen ohne Zutun weiterlaufen.
// 2. Die Erstinstallation über den update-agent schreibt Lizenzschlüssel und
// Deploymentcenter-Token als Klartext in die Settings.json — der Installer
// kennt unsere Verschlüsselung nicht. Beim ersten Speichern werden sie
// übernommen und verschlüsselt. Wer diesen Zweig entfernt, weil er nach
// Altlast aussieht, sperrt jede frisch installierte Instanz aus.
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("");
}
}