feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user