Alle Behebungen auf der Linux-Test-VM gegengeprueft (Ubuntu, .NET 10.0.111): Build 0 Warnungen / 0 Fehler, 637 Tests gruen, 1 uebersprungen (WindowsFact). Sicherheit und Zielplattform #373 PathBoundary.Canonicalize fiel offen aus. File.ResolveLinkTarget wirft fuer einen fehlenden Abschnitt FileNotFoundException; der catch(IOException) verliess daraufhin die GANZE Aufloesung mit dem unaufgeloesten Pfad - und der besteht jede Praefixpruefung. Der Symlink-Schutz war damit bei jedem Schreibvorgang wirkungslos. Jetzt ueber FileSystemInfo.LinkTarget, und TryCanonicalize meldet "nicht bestimmbar", statt fail-open durchzureichen. #374 WorkspacePath weist den Backslash rundheraus ab, wie den Doppelpunkt. Eine Schreibweise statt einer Sonderregel je Windows-Form. #375 Log-Verzeichnis: leer heisst "Anwendungseinstellung"; ein relativer Wert zaehlt gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses. Ein systemd-Dienst landet nicht mehr auf /Logs. Agentenkommunikation #206 Delegationskette gegen A->B->A (Test A13). Zyklusfrei per Bauart, dazu MaxDelegationDepth gegen die Staffel, die niemand beauftragt hat. #461 Zwei unabhaengige Ketten ueber Kreuz - beim Beheben von #206 gefunden und reproduziert. Ein delegierter Lauf wartet nur noch begrenzt auf ein fremdes Gate; ein Chat vom Menschen reiht sich weiter unbegrenzt ein. #234 Instanz-Id wird durchgereicht statt aus einem nur beilaeufig gesetzten Feld gelesen. Betrieb und Oberflaeche #376 Log-Ansicht chronologisch statt gruppiert je Moduldatei, mit Stacktraces an ihrer Meldung - auch ueber Taktgrenzen hinweg. #377 Info-Tabelle: feste Spaltenbreiten, damit ein langer Aenderungstext den anderen Spalten nicht den Platz nimmt. #378 Sicherung: Mindestabstand zwischen Ueberschrift und Schaltflaechenleiste. #379 Hellmodus: drei von vier gedaempften Textpinseln lagen unter 4,5:1. Die helle Palette ist als Ganzes verschoben, damit die Staffelung sichtbar bleibt statt auf einen Wert zusammenzufallen. Hygiene #233 stock_add: der Indexstand wird innerhalb der Sperre festgehalten. #235 Zeitumstellung: eine nicht existierende Ortszeit wird auf den ersten Zeitpunkt nach der Luecke verschoben statt uebersprungen (R4, R5). #236 45 Nullable-Warnungen und eine CA1416 - nicht unterdrueckt, sondern die Stellen so umgebaut, dass die Zusage nachvollziehbar wird. Mail meldet jetzt, WELCHER Zugangsschluessel fehlt, statt an einer KeyNotFoundException zu scheitern. #203 Kompaktiert wird vor dem API-Aufruf statt danach; die gemessenen Prompt-Groessen kalibrieren die Schaetzung je Modell. Neu: tests/ClawdDotNet.App.Tests - fuer #375 und #376 gab es keinen Ort fuer Tests. Dazu Regressionstests fuer jede Behebung; jeder wurde gegen den alten Stand geprueft, damit belegt ist, dass er den Fehler faengt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
13 KiB
C#
373 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.ShouldNotBeNull().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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>[LinuxFact]</c> hält den Test unter Windows an, der Plattform-Analysator sieht
|
|
/// das aber nicht — er kennt nur Attribute wie <c>SupportedOSPlatform</c>. Ohne die
|
|
/// Angabe blieb CA1416 als einzige Warnung im Build stehen, und eine einzelne
|
|
/// Warnung, die immer da ist, liest bald niemand mehr.
|
|
/// </summary>
|
|
[LinuxFact]
|
|
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
|
|
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.ShouldNotBeNull().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().ShouldNotBeNull().ShouldNotContain("geheim");
|
|
agent.Tools["Mail"]["password"]!.ToString().ShouldNotBeNull().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().ShouldNotBeNull().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("");
|
|
}
|
|
}
|