Files
ClawdDotNet/src/ClawdDotNet.Core/Security/SecretProtector.cs
T

180 lines
8.2 KiB
C#

using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Security;
/// <summary>
/// Verschlüsselt Zugangsdaten in Konfigurationsdateien.
///
/// Hintergrund (S7): OpenRouter-Schlüssel, Datenbank-Verbindungszeichenfolgen samt
/// Passwort, Mail-Zugangsdaten und das Telegram-2FA-Passwort lagen im Klartext in
/// AgentSettings.json und InstanceConfig.json. Wer die Dateien lesen konnte — ein
/// Backup, eine Dateifreigabe, ein versehentlicher Commit — hatte alle Zugänge.
///
/// <para><b>Zwei Formate.</b></para>
///
/// <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 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 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,
/// damit die Funktion gefahrlos mehrfach angewendet werden kann.
/// </summary>
public static string? Protect(string? plainText)
{
if (string.IsNullOrEmpty(plainText) || IsProtected(plainText))
return plainText;
try
{
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 (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
{
// 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);
}
}
/// <summary>
/// Entschlüsselt einen Wert. Klartext aus älteren Konfigurationen wird unverändert
/// zurückgegeben — so bleiben bestehende Installationen lauffähig.
/// </summary>
public static string? Unprotect(string? value)
{
if (string.IsNullOrEmpty(value)) return value;
if (value.StartsWith(PrefixV2, StringComparison.Ordinal))
return UnprotectV2(value[PrefixV2.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 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
or IOException or UnauthorizedAccessException)
{
throw new SecretProtectionException(
"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);
}
}
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)
=> ProtectedData.Unprotect(data, Entropy, DataProtectionScope.CurrentUser);
}
public sealed class SecretProtectionException(string message, Exception inner)
: Exception(message, inner);