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