180 lines
8.2 KiB
C#
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);
|