feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar

This commit is contained in:
Richard
2026-08-10 10:48:34 +02:00
parent a0e18d2a57
commit b5bf97ae74
187 changed files with 20054 additions and 882 deletions
@@ -45,6 +45,9 @@ public static class ConfigSecrets
telegram.Password2FA = transform(telegram.Password2FA);
}
if (config.Watchdog is { } watchdog)
watchdog.AgentToken = transform(watchdog.AgentToken) ?? "";
foreach (var agent in config.Agents)
Apply(agent, transform);
}
@@ -0,0 +1,107 @@
using System.Security.Cryptography;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Security;
/// <summary>
/// Verwaltet den lokalen Schlüssel, mit dem <see cref="SecretProtector"/> die
/// <c>enc:v2</c>-Werte sichert.
///
/// <para><b>Ein Schlüssel je Benutzer und Rechner.</b> Er liegt als
/// <c>secret.key</c> in <see cref="AppPaths.ConfigDirectory"/> — bewusst außerhalb
/// des Instanzverzeichnisses, damit eine Sicherung der Instanz ihn nicht mitnimmt.
/// Das hält die Schutzstufe, die DPAPI zuvor bot: Die Konfigurationsdatei allein
/// nützt auf einem anderen Rechner nichts.</para>
///
/// <para><b>Rechte.</b> Unter Unix <c>0600</c>. Unter Windows erbt die Datei die
/// Rechte des Benutzerprofils und wird zusätzlich per DPAPI gesichert — dort bleibt
/// die Bindung an das Benutzerkonto also erhalten, obwohl das Format
/// plattformübergreifend ist.</para>
/// </summary>
public static class SecretKeyStore
{
private const string KeyFileName = "secret.key";
private const int KeySize = 32; // AES-256
/// <summary>DPAPI-Zusatzkontext für die Schlüsseldatei (nur Windows).</summary>
private static readonly byte[] KeyEntropy =
System.Text.Encoding.UTF8.GetBytes("ClawdDotNet.SecretKey.v2");
private static readonly Lock Gate = new();
private static byte[]? _cached;
private static string? _overrideDirectory;
public static string KeyFilePath => Path.Combine(Directory(), KeyFileName);
/// <summary>
/// Verlegt den Schlüssel — für Tests, damit sie den echten Benutzerschlüssel weder
/// lesen noch überschreiben.
/// </summary>
public static void UseDirectory(string? directory)
{
lock (Gate)
{
_overrideDirectory = directory;
_cached = null;
}
}
/// <summary>
/// Liest den Schlüssel oder legt ihn beim ersten Aufruf an.
/// </summary>
/// <exception cref="IOException">Wenn das Verzeichnis nicht beschreibbar ist.</exception>
public static byte[] GetOrCreateKey()
{
lock (Gate)
{
if (_cached is not null) return _cached;
var directory = AppPaths.EnsureDirectory(Directory());
var path = Path.Combine(directory, KeyFileName);
if (File.Exists(path))
{
var stored = Unwrap(File.ReadAllBytes(path));
if (stored.Length == KeySize)
{
_cached = stored;
return _cached;
}
// Eine Datei falscher Länge ist kaputt. Sie stillschweigend zu ersetzen
// würde alle bestehenden Werte unlesbar machen, ohne dass jemand erfährt,
// warum — deshalb hier abbrechen und den Pfad nennen.
throw new CryptographicException(
$"Die Schlüsseldatei {path} ist beschädigt ({stored.Length} statt {KeySize} Byte). "
+ "Sie darf nicht ersetzt werden, ohne die verschlüsselten Werte neu zu setzen.");
}
var key = RandomNumberGenerator.GetBytes(KeySize);
// Über AtomicFile, damit kein halb geschriebener Schlüssel entsteht — der
// würde alle Geheimnisse dieser Installation unlesbar machen.
AtomicFile.WriteAllBytes(path, Wrap(key));
AppPaths.RestrictToOwner(path);
_cached = key;
return _cached;
}
}
private static string Directory() => _overrideDirectory ?? AppPaths.ConfigDirectory;
/// <summary>Unter Windows zusätzlich per DPAPI an das Benutzerkonto binden.</summary>
private static byte[] Wrap(byte[] key)
=> OperatingSystem.IsWindows() ? ProtectWithDpapi(key) : key;
private static byte[] Unwrap(byte[] stored)
=> OperatingSystem.IsWindows() ? UnprotectWithDpapi(stored) : stored;
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static byte[] ProtectWithDpapi(byte[] key)
=> ProtectedData.Protect(key, KeyEntropy, DataProtectionScope.CurrentUser);
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static byte[] UnprotectWithDpapi(byte[] stored)
=> ProtectedData.Unprotect(stored, KeyEntropy, DataProtectionScope.CurrentUser);
}
+114 -33
View File
@@ -1,6 +1,7 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Security;
@@ -12,23 +13,46 @@ namespace ClawdDotNet.Core.Security;
/// 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.
/// <para><b>Zwei Formate.</b></para>
///
/// Verschlüsselte Werte tragen ein Präfix, damit Klartext aus älteren Konfigurationen
/// weiterhin gelesen und beim nächsten Speichern automatisch übernommen wird.
/// <list type="bullet">
/// <item><c>enc:v1:</c> — DPAPI im Benutzerkontext. Nur unter Windows lesbar. Wird
/// nicht mehr geschrieben, aber weiterhin gelesen: bestehende Installationen sollen
/// ohne Zutun weiterlaufen und wandern beim nächsten Speichern von selbst auf v2.</item>
/// <item><c>enc:v2:</c> — AES-256-GCM mit einem Schlüssel aus
/// <see cref="AppPaths.ConfigDirectory"/>. Läuft auf jeder Plattform.</item>
/// </list>
///
/// <para><b>Warum v2 überhaupt nötig wurde.</b> Die vorige Fassung gab unter Linux
/// stillschweigend den Klartext zurück — <c>Protect</c> verschlüsselte dort schlicht
/// nicht. Auf einem Server, der per SSH erreichbar ist und gesichert wird, wäre das
/// schlechter gewesen als auf einem Einzelplatz-Windows.</para>
///
/// <para><b>Schutzstufe.</b> Dieselbe wie DPAPI zuvor: gegen Weitergabe der
/// Konfigurationsdatei, gegen ein Backup, gegen einen versehentlichen Commit — nicht
/// gegen einen Angreifer, der bereits als dieser Benutzer läuft. Der Schlüssel liegt
/// deshalb bewusst <b>außerhalb</b> des Instanzverzeichnisses: Eine Sicherung der
/// Instanz enthält ihn nicht, und ob Geheimnisse mitreisen, entscheidet weiterhin
/// allein die Sicherungsrichtlinie in <c>BackupService</c>.</para>
/// </summary>
public static class SecretProtector
{
private const string Prefix = "enc:v1:";
private const string PrefixV1 = "enc:v1:";
private const string PrefixV2 = "enc:v2:";
/// <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");
/// <summary>Wird v2 als Zusatzangabe mitgeschrieben und beim Entschlüsseln geprüft.</summary>
private static readonly byte[] AssociatedData = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v2");
private const int NonceSize = 12; // AES-GCM: vorgeschriebene Länge
private const int TagSize = 16;
public static bool IsProtected(string? value)
=> value?.StartsWith(Prefix, StringComparison.Ordinal) == true;
=> value is not null
&& (value.StartsWith(PrefixV2, StringComparison.Ordinal)
|| value.StartsWith(PrefixV1, StringComparison.Ordinal));
/// <summary>
/// Verschlüsselt einen Wert. Bereits verschlüsselte und leere Werte bleiben unverändert,
@@ -39,18 +63,35 @@ public static class SecretProtector
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);
var key = SecretKeyStore.GetOrCreateKey();
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var plain = Encoding.UTF8.GetBytes(plainText);
var cipher = new byte[plain.Length];
var tag = new byte[TagSize];
using (var aes = new AesGcm(key, TagSize))
aes.Encrypt(nonce, plain, cipher, tag, AssociatedData);
// nonce ‖ tag ‖ ciphertext — feste Längen vorn, damit das Zerlegen eindeutig ist.
var payload = new byte[NonceSize + TagSize + cipher.Length];
nonce.CopyTo(payload, 0);
tag.CopyTo(payload, NonceSize);
cipher.CopyTo(payload, NonceSize + TagSize);
return PrefixV2 + Convert.ToBase64String(payload);
}
catch (CryptographicException)
catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
{
// Lieber unverschlüsselt weiterarbeiten als die Konfiguration verlieren.
return plainText;
// Kein Schlüssel anlegbar (etwa ein schreibgeschütztes Konfigurationsverzeichnis).
// Den Wert unverschlüsselt zu speichern wäre die stille Rückkehr zu genau dem
// Zustand, den S7 behoben hat — deshalb hier abbrechen statt weiterreichen.
throw new SecretProtectionException(
"Ein Wert konnte nicht verschlüsselt werden, weil der lokale Schlüssel nicht "
+ $"lesbar oder anlegbar ist ({SecretKeyStore.KeyFilePath}). Ohne ihn würden "
+ "Zugangsdaten im Klartext gespeichert — der Vorgang wurde abgebrochen.", ex);
}
}
@@ -60,34 +101,74 @@ public static class SecretProtector
/// </summary>
public static string? Unprotect(string? value)
{
if (string.IsNullOrEmpty(value) || !IsProtected(value))
return value;
if (string.IsNullOrEmpty(value)) return value;
if (!OperatingSystem.IsWindows())
return value;
if (value.StartsWith(PrefixV2, StringComparison.Ordinal))
return UnprotectV2(value[PrefixV2.Length..]);
var payload = value[Prefix.Length..];
if (value.StartsWith(PrefixV1, StringComparison.Ordinal))
return UnprotectV1(value[PrefixV1.Length..]);
return value; // Klartext aus älteren Konfigurationen
}
private static string UnprotectV2(string payload)
{
try
{
var decrypted = UnprotectWindows(Convert.FromBase64String(payload));
return Encoding.UTF8.GetString(decrypted);
var raw = Convert.FromBase64String(payload);
if (raw.Length < NonceSize + TagSize)
throw new CryptographicException("Der verschlüsselte Block ist unvollständig.");
var key = SecretKeyStore.GetOrCreateKey();
var nonce = raw.AsSpan(0, NonceSize);
var tag = raw.AsSpan(NonceSize, TagSize);
var cipher = raw.AsSpan(NonceSize + TagSize);
var plain = new byte[cipher.Length];
using (var aes = new AesGcm(key, TagSize))
aes.Decrypt(nonce, cipher, tag, plain, AssociatedData);
return Encoding.UTF8.GetString(plain);
}
catch (Exception ex) when (ex is CryptographicException or FormatException)
catch (Exception ex) when (ex is CryptographicException or FormatException
or IOException or UnauthorizedAccessException)
{
// 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);
"Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die "
+ "Konfiguration von einem anderen Rechner oder Benutzer stammt — der Schlüssel "
+ $"dazu liegt in {SecretKeyStore.KeyFilePath} und reist nicht mit. "
+ "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
}
}
[SupportedOSPlatform("windows")]
private static byte[] ProtectWindows(byte[] data)
=> ProtectedData.Protect(data, Entropy, DataProtectionScope.CurrentUser);
private static string UnprotectV1(string payload)
{
if (!OperatingSystem.IsWindows())
{
// Der Fall beim Umzug einer Windows-Instanz auf Linux. Ihn als Klartext
// durchzureichen wäre falsch — dann ginge ein unbrauchbarer Schlüssel an die API.
throw new SecretProtectionException(
"Dieser Wert wurde mit der Windows-Verschlüsselung (DPAPI) gesichert und lässt "
+ "sich hier nicht lesen. Beim Umzug einer Instanz von Windows müssen die "
+ "betroffenen Werte einmal neu eingetragen werden; danach liegen sie im "
+ "plattformübergreifenden Format vor.",
new PlatformNotSupportedException("DPAPI ist nur unter Windows verfügbar."));
}
try
{
return Encoding.UTF8.GetString(UnprotectWindows(Convert.FromBase64String(payload)));
}
catch (Exception ex) when (ex is CryptographicException or FormatException)
{
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[] UnprotectWindows(byte[] data)