Files
ClawdDotNet/src/ClawdDotNet.Core/Security/PassphraseProtector.cs
T
RichardandClaude Opus 4.8 b55512e734 Backup und Wiederherstellung einer Instanz
Gesichert wird alles, was sich nicht wiederherstellen laesst: Identity und Soul,
Konfigurationen, Arbeitsverzeichnisse, Chatverlaeufe und vor allem die Datenbank
mit dem Langzeitgedaechtnis. Protokolle bleiben standardmaessig aussen vor.

Zwei Punkte waren dabei nicht offensichtlich.

Die Datenbank darf nicht einfach kopiert werden. Mit WAL stehen die juengsten
Aenderungen in der Begleitdatei, nicht in der Hauptdatei — eine reine Kopie waere
veraltet oder in sich widersprueckhlich. VACUUM INTO erzeugt dagegen im laufenden
Betrieb eine geschlossene, konsistente Kopie; die WAL-Begleitdateien gehoeren dann
nicht mehr ins Archiv.

Zugangsdaten sind seit S7 mit DPAPI geschuetzt und damit an Benutzer und Rechner
gebunden. In einer Sicherung waeren sie genau dann unbrauchbar, wenn man sie
braucht — bei einem defekten Rechner. PassphraseProtector schluesselt sie deshalb
beim Sichern auf eine Passphrase um (PBKDF2 mit 210.000 Runden, AES-GCM) und beim
Wiederherstellen zurueck auf DPAPI des Zielrechners. Alternativ laesst sich eine
Sicherung ganz ohne Zugangsdaten erstellen; sie ist dann gefahrlos ablegbar, die
Wiederherstellung aber unvollstaendig.

Das Umschluesseln arbeitet auf dem JSON-Baum statt ueber die typisierten
Konfigurationsklassen. Beim Deserialisieren und erneuten Serialisieren gingen
unbekannte Felder verloren — eine Sicherung darf aber nichts wegwerfen, nur weil
eine Programmfassung ein Feld nicht kennt. Ein Test haelt das fest.

Weitere Eigenschaften: Manifest mit Pruefsummen je Datei, sodass ein veraendertes
Archiv auffaellt, bevor etwas ueberschrieben wird. Vorschau-Modus. Vorhandene
Dateien werden ohne ausdrueckliche Zustimmung nicht ueberschrieben. Eintraege, die
aus dem Zielverzeichnis herauszeigen, werden abgelehnt.

Der wichtigste Test ist der vollstaendige Rundlauf: Instanz aufbauen, sichern, in
ein leeres Verzeichnis wiederherstellen und pruefen, dass Persoenlichkeit,
Arbeitsstand, Gedaechtnis und nutzbare Zugangsdaten zurueck sind. Ein ungepruefte
Wiederherstellung ist kein Backup, sondern eine Vermutung.

385 Tests gruen (237 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:00:42 +02:00

121 lines
4.4 KiB
C#

using System.Security.Cryptography;
using System.Text;
namespace ClawdDotNet.Core.Security;
/// <summary>
/// Verschlüsselt Werte mit einer Passphrase statt mit DPAPI.
///
/// Hintergrund: <see cref="SecretProtector"/> nutzt DPAPI im Benutzerkontext —
/// entschlüsseln kann nur derselbe Windows-Benutzer auf demselben Rechner. Für den
/// laufenden Betrieb ist das richtig, für ein Backup jedoch untauglich: Ein Backup
/// wird gerade dann gebraucht, wenn der Rechner defekt ist. Die Zugangsdaten darin
/// wären auf dem Ersatzrechner nicht lesbar.
///
/// Deshalb werden Zugangsdaten beim Sichern auf eine Passphrase umgeschlüsselt und
/// beim Wiederherstellen zurück auf DPAPI.
///
/// Aufbau eines geschützten Werts:
/// <code>pbe:v1:&lt;salt&gt;:&lt;nonce&gt;:&lt;tag&gt;:&lt;ciphertext&gt;</code>
/// Alle Teile Base64. Jeder Wert bekommt ein eigenes Salz und einen eigenen Nonce —
/// gleiche Klartexte ergeben dadurch unterschiedliche Chiffrate.
/// </summary>
public static class PassphraseProtector
{
private const string Prefix = "pbe:v1:";
private const int SaltBytes = 16;
private const int NonceBytes = 12; // AES-GCM Standard
private const int TagBytes = 16;
private const int KeyBytes = 32; // AES-256
/// <summary>
/// Rundenzahl der Schlüsselableitung. Hoch genug, um Rateversuche teuer zu machen,
/// niedrig genug für ein Backup mit vielen Einzelwerten.
/// </summary>
private const int Iterations = 210_000;
public static bool IsProtected(string? value)
=> value?.StartsWith(Prefix, StringComparison.Ordinal) == true;
public static string? Protect(string? plainText, string passphrase)
{
if (string.IsNullOrEmpty(plainText))
return plainText;
if (string.IsNullOrEmpty(passphrase))
throw new ArgumentException("Passphrase darf nicht leer sein.", nameof(passphrase));
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
var nonce = RandomNumberGenerator.GetBytes(NonceBytes);
var key = DeriveKey(passphrase, salt);
var plain = Encoding.UTF8.GetBytes(plainText);
var cipher = new byte[plain.Length];
var tag = new byte[TagBytes];
using (var aes = new AesGcm(key, TagBytes))
aes.Encrypt(nonce, plain, cipher, tag);
CryptographicOperations.ZeroMemory(key);
return Prefix
+ Convert.ToBase64String(salt) + ":"
+ Convert.ToBase64String(nonce) + ":"
+ Convert.ToBase64String(tag) + ":"
+ Convert.ToBase64String(cipher);
}
public static string? Unprotect(string? value, string passphrase)
{
if (string.IsNullOrEmpty(value) || !IsProtected(value))
return value;
var parts = value[Prefix.Length..].Split(':');
if (parts.Length != 4)
throw new SecretProtectionException(
"Der geschützte Wert ist unvollständig oder beschädigt.",
new FormatException("Erwartet werden vier Abschnitte."));
byte[] salt, nonce, tag, cipher;
try
{
salt = Convert.FromBase64String(parts[0]);
nonce = Convert.FromBase64String(parts[1]);
tag = Convert.FromBase64String(parts[2]);
cipher = Convert.FromBase64String(parts[3]);
}
catch (FormatException ex)
{
throw new SecretProtectionException("Der geschützte Wert ist beschädigt.", ex);
}
var key = DeriveKey(passphrase, salt);
var plain = new byte[cipher.Length];
try
{
using var aes = new AesGcm(key, TagBytes);
aes.Decrypt(nonce, cipher, tag, plain);
}
catch (CryptographicException ex)
{
// AES-GCM erkennt sowohl eine falsche Passphrase als auch nachträgliche
// Veränderung — beides landet hier.
throw new SecretProtectionException(
"Entschlüsselung fehlgeschlagen. Entweder ist die Passphrase falsch " +
"oder die Sicherung wurde verändert.", ex);
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
return Encoding.UTF8.GetString(plain);
}
private static byte[] DeriveKey(string passphrase, byte[] salt)
=> Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(passphrase), salt, Iterations, HashAlgorithmName.SHA256, KeyBytes);
}