diff --git a/src/ClawdDotNet.Core/Backup/BackupModels.cs b/src/ClawdDotNet.Core/Backup/BackupModels.cs
new file mode 100644
index 0000000..7bc5479
--- /dev/null
+++ b/src/ClawdDotNet.Core/Backup/BackupModels.cs
@@ -0,0 +1,92 @@
+using System.Text.Json.Serialization;
+
+namespace ClawdDotNet.Core.Backup;
+
+/// Wie mit Zugangsdaten in der Sicherung verfahren wird.
+public enum SecretMode
+{
+ ///
+ /// Zugangsdaten werden entfernt. Die Sicherung ist gefahrlos ablegbar, die
+ /// Wiederherstellung aber unvollständig — Schlüssel und Passwörter müssen
+ /// danach neu eingetragen werden.
+ ///
+ Exclude,
+
+ ///
+ /// Zugangsdaten werden mit einer Passphrase geschützt. Nur so überstehen sie
+ /// einen Rechner- oder Benutzerwechsel.
+ ///
+ Passphrase
+}
+
+public sealed record BackupOptions
+{
+ public SecretMode Secrets { get; init; } = SecretMode.Exclude;
+
+ /// Erforderlich bei .
+ public string? Passphrase { get; init; }
+
+ /// Protokolle sind groß und selten nötig.
+ public bool IncludeLogs { get; init; }
+
+ /// Chatverläufe gehören zum Arbeitsstand, können aber umfangreich sein.
+ public bool IncludeChatHistory { get; init; } = true;
+}
+
+public sealed record RestoreOptions
+{
+ public string? Passphrase { get; init; }
+
+ /// Nur prüfen und berichten, nichts schreiben.
+ public bool DryRun { get; init; }
+
+ /// Vorhandene Dateien im Ziel überschreiben.
+ public bool Overwrite { get; init; }
+}
+
+public sealed record BackupEntry(
+ [property: JsonPropertyName("path")] string Path,
+ [property: JsonPropertyName("size")] long Size,
+ [property: JsonPropertyName("sha256")] string Sha256);
+
+public sealed record BackupManifest
+{
+ /// Erlaubt es späteren Fassungen, ältere Sicherungen zu erkennen.
+ [JsonPropertyName("formatVersion")]
+ public int FormatVersion { get; init; } = 1;
+
+ [JsonPropertyName("createdAt")]
+ public DateTime CreatedAt { get; init; }
+
+ [JsonPropertyName("instanceId")]
+ public string InstanceId { get; init; } = "";
+
+ [JsonPropertyName("instanceName")]
+ public string InstanceName { get; init; } = "";
+
+ [JsonPropertyName("secrets")]
+ public string Secrets { get; init; } = nameof(SecretMode.Exclude);
+
+ /// Wie viele Zugangsdaten enthalten bzw. entfernt wurden.
+ [JsonPropertyName("secretCount")]
+ public int SecretCount { get; init; }
+
+ [JsonPropertyName("files")]
+ public List Files { get; init; } = new();
+
+ [JsonIgnore]
+ public bool HasSecrets => Secrets == nameof(SecretMode.Passphrase);
+}
+
+public sealed record BackupResult(string ZipPath, BackupManifest Manifest, long SizeBytes);
+
+public sealed record RestoreResult(
+ IReadOnlyList Written,
+ IReadOnlyList Skipped,
+ IReadOnlyList WouldOverwrite)
+{
+ public bool HasConflicts => WouldOverwrite.Count > 0;
+}
+
+public sealed class BackupException(string message, Exception? inner = null)
+ : Exception(message, inner);
diff --git a/src/ClawdDotNet.Core/Backup/BackupService.cs b/src/ClawdDotNet.Core/Backup/BackupService.cs
new file mode 100644
index 0000000..9e3f774
--- /dev/null
+++ b/src/ClawdDotNet.Core/Backup/BackupService.cs
@@ -0,0 +1,395 @@
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using ClawdDotNet.Core.Security;
+using ClawdDotNet.Core.Storage;
+using Microsoft.Data.Sqlite;
+
+namespace ClawdDotNet.Core.Backup;
+
+///
+/// Sichert eine Instanz vollständig und stellt sie wieder her.
+///
+/// Zwei Dinge sind dabei nicht offensichtlich:
+///
+/// 1. Die Datenbank darf nicht einfach kopiert werden. Mit WAL stehen die jüngsten
+/// Änderungen in der Begleitdatei, nicht in der Hauptdatei — eine reine Kopie wäre
+/// veraltet oder in sich widersprüchlich. VACUUM INTO erzeugt dagegen im
+/// laufenden Betrieb eine geschlossene, konsistente Kopie.
+///
+/// 2. Zugangsdaten sind mit DPAPI geschützt und damit an Benutzer und Rechner
+/// gebunden. In einer Sicherung wären sie genau dann unbrauchbar, wenn man sie
+/// braucht. Sie werden deshalb auf eine Passphrase umgeschlüsselt — oder auf
+/// Wunsch weggelassen.
+///
+public sealed class BackupService
+{
+ private const string ManifestName = "manifest.json";
+ private const string DatabaseName = "state.db";
+
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ /// Wird nie mitgesichert — entweder erzeugt oder unerwünscht.
+ private static readonly string[] AlwaysExcludedDirectories = ["bin", "obj", ".vs"];
+
+ private static readonly string[] AlwaysExcludedExtensions = [".tmp", ".bak"];
+
+ // ═══════════════════════════════════════════════════════════
+ // Sichern
+ // ═══════════════════════════════════════════════════════════
+
+ public async Task CreateAsync(
+ string instanceDir, string targetZipPath, BackupOptions options, CancellationToken ct = default)
+ {
+ if (!Directory.Exists(instanceDir))
+ throw new BackupException($"Instanzverzeichnis nicht gefunden: {instanceDir}");
+
+ if (options.Secrets == SecretMode.Passphrase && string.IsNullOrEmpty(options.Passphrase))
+ throw new BackupException("Für den Schutz der Zugangsdaten wird eine Passphrase benötigt.");
+
+ var staging = Path.Combine(Path.GetTempPath(), "clawd-backup-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(staging);
+
+ try
+ {
+ var files = new List();
+ var secretCount = 0;
+
+ // ─── Datenbank konsistent kopieren ───
+ var dbPath = Path.Combine(instanceDir, DatabaseName);
+ if (File.Exists(dbPath))
+ {
+ var target = Path.Combine(staging, DatabaseName);
+ CopyDatabaseConsistently(dbPath, target);
+ files.Add(await DescribeAsync(staging, target, ct));
+ }
+
+ // ─── Übrige Dateien ───
+ foreach (var source in EnumerateFiles(instanceDir, options))
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var relative = Path.GetRelativePath(instanceDir, source);
+ var target = Path.Combine(staging, relative);
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+
+ if (IsConfigFile(relative))
+ {
+ var original = AtomicFile.ReadAllText(source);
+ var (rewritten, count) = RewriteSecretsForBackup(original, options);
+ secretCount += count;
+ await File.WriteAllTextAsync(target, rewritten, ct);
+ }
+ else
+ {
+ File.Copy(source, target, overwrite: true);
+ }
+
+ files.Add(await DescribeAsync(staging, target, ct));
+ }
+
+ // ─── Manifest ───
+ var (instanceId, instanceName) = ReadInstanceIdentity(instanceDir);
+
+ var manifest = new BackupManifest
+ {
+ CreatedAt = DateTime.Now,
+ InstanceId = instanceId,
+ InstanceName = instanceName,
+ Secrets = options.Secrets.ToString(),
+ SecretCount = secretCount,
+ Files = files.OrderBy(f => f.Path, StringComparer.OrdinalIgnoreCase).ToList()
+ };
+
+ await File.WriteAllTextAsync(
+ Path.Combine(staging, ManifestName),
+ JsonSerializer.Serialize(manifest, JsonOptions), ct);
+
+ // ─── Archiv ───
+ Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(targetZipPath))!);
+ if (File.Exists(targetZipPath))
+ File.Delete(targetZipPath);
+
+ ZipFile.CreateFromDirectory(staging, targetZipPath, CompressionLevel.Optimal,
+ includeBaseDirectory: false);
+
+ return new BackupResult(targetZipPath, manifest, new FileInfo(targetZipPath).Length);
+ }
+ finally
+ {
+ TryDeleteDirectory(staging);
+ }
+ }
+
+ ///
+ /// Erzeugt eine konsistente Kopie der Datenbank, auch während sie in Benutzung ist.
+ ///
+ private static void CopyDatabaseConsistently(string sourcePath, string targetPath)
+ {
+ var connectionString = new SqliteConnectionStringBuilder
+ {
+ DataSource = sourcePath,
+ Mode = SqliteOpenMode.ReadOnly
+ }.ToString();
+
+ using var conn = new SqliteConnection(connectionString);
+ conn.Open();
+
+ using var cmd = conn.CreateCommand();
+ // Parameter sind in VACUUM INTO nicht erlaubt, deshalb einfache Anführungszeichen
+ // im Pfad verdoppeln.
+ cmd.CommandText = $"VACUUM INTO '{targetPath.Replace("'", "''")}'";
+ cmd.ExecuteNonQuery();
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // Prüfen
+ // ═══════════════════════════════════════════════════════════
+
+ /// Liest das Manifest, ohne etwas auszupacken.
+ public async Task InspectAsync(string zipPath, CancellationToken ct = default)
+ {
+ using var archive = ZipFile.OpenRead(zipPath);
+
+ var entry = archive.GetEntry(ManifestName)
+ ?? throw new BackupException("Kein Manifest im Archiv — das ist keine ClawdDotNet-Sicherung.");
+
+ await using var stream = entry.Open();
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+
+ var json = await reader.ReadToEndAsync(ct);
+
+ return JsonSerializer.Deserialize(json)
+ ?? throw new BackupException("Das Manifest ist unlesbar.");
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // Wiederherstellen
+ // ═══════════════════════════════════════════════════════════
+
+ public async Task RestoreAsync(
+ string zipPath, string targetDir, RestoreOptions options, CancellationToken ct = default)
+ {
+ var manifest = await InspectAsync(zipPath, ct);
+
+ if (manifest.HasSecrets && string.IsNullOrEmpty(options.Passphrase))
+ throw new BackupException(
+ "Diese Sicherung enthält geschützte Zugangsdaten. Bitte die Passphrase angeben.");
+
+ var written = new List();
+ var skipped = new List();
+ var wouldOverwrite = new List();
+
+ using var archive = ZipFile.OpenRead(zipPath);
+
+ foreach (var entry in archive.Entries)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ if (entry.FullName == ManifestName || string.IsNullOrEmpty(entry.Name))
+ continue;
+
+ var relative = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
+ var destination = ResolveInside(targetDir, relative);
+
+ // Prüfsumme gegen das Manifest — ein beschädigtes Archiv soll auffallen,
+ // bevor etwas überschrieben wird.
+ var expected = manifest.Files.FirstOrDefault(
+ f => string.Equals(f.Path, entry.FullName, StringComparison.OrdinalIgnoreCase));
+
+ var content = await ReadEntryAsync(entry, ct);
+
+ if (expected is not null && ComputeSha256(content) != expected.Sha256)
+ {
+ throw new BackupException(
+ $"Prüfsumme stimmt nicht für '{entry.FullName}'. Das Archiv ist beschädigt.");
+ }
+
+ if (File.Exists(destination))
+ {
+ wouldOverwrite.Add(relative);
+
+ if (!options.Overwrite)
+ {
+ skipped.Add(relative);
+ continue;
+ }
+ }
+
+ if (options.DryRun)
+ continue;
+
+ var restored = manifest.HasSecrets && IsConfigFile(relative)
+ ? Encoding.UTF8.GetBytes(
+ RewriteSecretsForRestore(Encoding.UTF8.GetString(content), options.Passphrase!))
+ : content;
+
+ AtomicFile.WriteAllBytes(destination, restored);
+ written.Add(relative);
+ }
+
+ return new RestoreResult(written, skipped, wouldOverwrite);
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // Zugangsdaten
+ // ═══════════════════════════════════════════════════════════
+
+ private static (string Json, int SecretCount) RewriteSecretsForBackup(
+ string json, BackupOptions options)
+ {
+ var count = 0;
+
+ var rewritten = JsonSecretRewriter.Rewrite(json, value =>
+ {
+ count++;
+
+ // In der Datei liegt der Wert DPAPI-geschützt; für die Sicherung wird er
+ // zunächst gelesen und dann anders geschützt.
+ string? plain;
+ try
+ {
+ plain = SecretProtector.Unprotect(value);
+ }
+ catch (SecretProtectionException)
+ {
+ // Nicht lesbar — etwa weil die Datei von einem anderen Benutzer stammt.
+ // Der Wert darf dann nicht als vermeintlicher Klartext weitergereicht
+ // werden.
+ return null;
+ }
+
+ return options.Secrets == SecretMode.Passphrase
+ ? PassphraseProtector.Protect(plain, options.Passphrase!)
+ : null;
+ });
+
+ return (rewritten, count);
+ }
+
+ private static string RewriteSecretsForRestore(string json, string passphrase)
+ => JsonSecretRewriter.Rewrite(json, value =>
+ {
+ if (!PassphraseProtector.IsProtected(value))
+ return value;
+
+ var plain = PassphraseProtector.Unprotect(value, passphrase);
+
+ // Zurück auf DPAPI des Zielrechners.
+ return SecretProtector.Protect(plain);
+ });
+
+ // ═══════════════════════════════════════════════════════════
+ // Hilfsfunktionen
+ // ═══════════════════════════════════════════════════════════
+
+ private static IEnumerable EnumerateFiles(string instanceDir, BackupOptions options)
+ {
+ foreach (var path in Directory.EnumerateFiles(instanceDir, "*", SearchOption.AllDirectories))
+ {
+ var relative = Path.GetRelativePath(instanceDir, path);
+ var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+
+ if (segments.Any(s => AlwaysExcludedDirectories.Contains(s, StringComparer.OrdinalIgnoreCase)))
+ continue;
+
+ var name = Path.GetFileName(path);
+
+ // Die Datenbank wird gesondert behandelt; die WAL-Begleitdateien gehören
+ // nicht ins Archiv, weil VACUUM INTO sie bereits einarbeitet.
+ if (name is DatabaseName or DatabaseName + "-wal" or DatabaseName + "-shm")
+ continue;
+
+ if (AlwaysExcludedExtensions.Contains(Path.GetExtension(name), StringComparer.OrdinalIgnoreCase))
+ continue;
+
+ if (name.Contains(".tmp_", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ if (!options.IncludeLogs &&
+ segments.Any(s => s.Equals("Logs", StringComparison.OrdinalIgnoreCase)))
+ continue;
+
+ if (!options.IncludeChatHistory &&
+ name is "ChatHistory.json" or "ChatContext.json")
+ continue;
+
+ yield return path;
+ }
+ }
+
+ private static bool IsConfigFile(string relativePath)
+ {
+ var name = Path.GetFileName(relativePath);
+ return name.Equals("InstanceSettings.json", StringComparison.OrdinalIgnoreCase)
+ || name.Equals("AgentSettings.json", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static (string Id, string Name) ReadInstanceIdentity(string instanceDir)
+ {
+ var path = Path.Combine(instanceDir, "InstanceSettings.json");
+ if (!File.Exists(path))
+ return ("", Path.GetFileName(instanceDir.TrimEnd(Path.DirectorySeparatorChar)));
+
+ try
+ {
+ using var doc = JsonDocument.Parse(AtomicFile.ReadAllText(path));
+ var root = doc.RootElement;
+
+ return (
+ root.TryGetProperty("instanceId", out var id) ? id.GetString() ?? "" : "",
+ root.TryGetProperty("instanceName", out var n) ? n.GetString() ?? "" : "");
+ }
+ catch (JsonException)
+ {
+ return ("", "");
+ }
+ }
+
+ private static async Task DescribeAsync(string root, string file, CancellationToken ct)
+ {
+ var bytes = await File.ReadAllBytesAsync(file, ct);
+
+ return new BackupEntry(
+ Path.GetRelativePath(root, file).Replace(Path.DirectorySeparatorChar, '/'),
+ bytes.LongLength,
+ ComputeSha256(bytes));
+ }
+
+ private static async Task ReadEntryAsync(ZipArchiveEntry entry, CancellationToken ct)
+ {
+ await using var stream = entry.Open();
+ using var buffer = new MemoryStream();
+ await stream.CopyToAsync(buffer, ct);
+ return buffer.ToArray();
+ }
+
+ private static string ComputeSha256(byte[] content)
+ => Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant();
+
+ ///
+ /// Verhindert, dass ein präpariertes Archiv über Einträge wie ..\..\evil
+ /// außerhalb des Zielverzeichnisses schreibt.
+ ///
+ private static string ResolveInside(string targetDir, string relative)
+ {
+ var root = Path.GetFullPath(targetDir);
+ var full = Path.GetFullPath(Path.Combine(root, relative));
+
+ var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar)
+ ? root
+ : root + Path.DirectorySeparatorChar;
+
+ if (!full.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase))
+ throw new BackupException($"Eintrag '{relative}' zeigt aus dem Zielverzeichnis heraus.");
+
+ return full;
+ }
+
+ private static void TryDeleteDirectory(string path)
+ {
+ try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
+ catch { /* Aufräumen darf den eigentlichen Vorgang nicht stören */ }
+ }
+}
diff --git a/src/ClawdDotNet.Core/Security/JsonSecretRewriter.cs b/src/ClawdDotNet.Core/Security/JsonSecretRewriter.cs
new file mode 100644
index 0000000..bb49c70
--- /dev/null
+++ b/src/ClawdDotNet.Core/Security/JsonSecretRewriter.cs
@@ -0,0 +1,85 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ClawdDotNet.Core.Security;
+
+///
+/// Schreibt Zugangsdaten in einer JSON-Datei um, ohne sonst etwas zu verändern.
+///
+/// Bewusst auf dem JSON-Baum statt über die typisierten Konfigurationsklassen:
+/// Beim Deserialisieren und erneuten Serialisieren gingen unbekannte Felder verloren.
+/// Eine Sicherung darf aber nichts wegwerfen, nur weil eine ältere Programmfassung
+/// ein Feld nicht kennt.
+///
+/// Welche Felder betroffen sind, entscheidet —
+/// dieselbe Liste wie im laufenden Betrieb.
+///
+public static class JsonSecretRewriter
+{
+ private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
+
+ private static readonly JsonNodeOptions NodeOptions = new() { PropertyNameCaseInsensitive = false };
+
+ private static readonly JsonDocumentOptions DocumentOptions = new()
+ {
+ CommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true
+ };
+
+ ///
+ /// Wendet auf alle Werte an, deren Feldname als
+ /// Zugangsdatum gilt. Gibt das neue JSON zurück.
+ ///
+ public static string Rewrite(string json, Func transform)
+ {
+ var root = JsonNode.Parse(json, NodeOptions, DocumentOptions);
+ if (root is null)
+ return json;
+
+ Walk(root, transform);
+
+ return root.ToJsonString(WriteOptions);
+ }
+
+ /// Zählt, wie viele Zugangsdaten in der Datei stecken — für das Manifest.
+ public static int CountSecrets(string json)
+ {
+ var count = 0;
+ Rewrite(json, value => { count++; return value; });
+ return count;
+ }
+
+ private static void Walk(JsonNode node, Func transform)
+ {
+ switch (node)
+ {
+ case JsonObject obj:
+ // Über eine Kopie laufen, weil Werte im Zuge ersetzt werden.
+ foreach (var (name, child) in obj.ToList())
+ {
+ if (child is null)
+ continue;
+
+ if (ConfigSecrets.IsSecretKey(name) &&
+ child is JsonValue value &&
+ value.TryGetValue(out var text) &&
+ !string.IsNullOrEmpty(text))
+ {
+ obj[name] = transform(text);
+ continue;
+ }
+
+ Walk(child, transform);
+ }
+ break;
+
+ case JsonArray array:
+ foreach (var child in array)
+ {
+ if (child is not null)
+ Walk(child, transform);
+ }
+ break;
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Core/Security/PassphraseProtector.cs b/src/ClawdDotNet.Core/Security/PassphraseProtector.cs
new file mode 100644
index 0000000..0614b96
--- /dev/null
+++ b/src/ClawdDotNet.Core/Security/PassphraseProtector.cs
@@ -0,0 +1,120 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace ClawdDotNet.Core.Security;
+
+///
+/// Verschlüsselt Werte mit einer Passphrase statt mit DPAPI.
+///
+/// Hintergrund: 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:
+/// pbe:v1:<salt>:<nonce>:<tag>:<ciphertext>
+/// Alle Teile Base64. Jeder Wert bekommt ein eigenes Salz und einen eigenen Nonce —
+/// gleiche Klartexte ergeben dadurch unterschiedliche Chiffrate.
+///
+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
+
+ ///
+ /// Rundenzahl der Schlüsselableitung. Hoch genug, um Rateversuche teuer zu machen,
+ /// niedrig genug für ein Backup mit vielen Einzelwerten.
+ ///
+ 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);
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs b/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs
new file mode 100644
index 0000000..7d70689
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Backup/BackupServiceTests.cs
@@ -0,0 +1,429 @@
+using System.IO.Compression;
+using System.Text.Json;
+using ClawdDotNet.Core.Backup;
+using ClawdDotNet.Core.Memory;
+using ClawdDotNet.Core.Security;
+using ClawdDotNet.Core.Storage;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Backup;
+
+///
+/// Ein ungeprüftes Wiederherstellen ist kein Backup, sondern eine Vermutung.
+/// Deshalb liegt der Schwerpunkt hier auf dem vollständigen Rundlauf.
+///
+public sealed class BackupServiceTests : IDisposable
+{
+ private readonly string _root;
+ private readonly string _instanceDir;
+ private readonly BackupService _service = new();
+
+ private const string ApiKeyPlain = "sk-or-v1-streng-geheim-12345";
+ private const string MailPasswordPlain = "mail-passwort-geheim";
+
+ public BackupServiceTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
+ _instanceDir = Path.Combine(_root, "Instance-Test");
+ Directory.CreateDirectory(_instanceDir);
+ }
+
+ public void Dispose()
+ {
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ try { Directory.Delete(_root, recursive: true); }
+ catch { /* Aufräumen ist Nebensache */ }
+ }
+
+ // ─── Aufbau einer realistischen Instanz ───
+
+ private async Task BuildInstanceAsync()
+ {
+ // Instanzkonfiguration — Zugangsdaten liegen DPAPI-geschützt wie im Betrieb.
+ var instanceSettings = $$"""
+ {
+ "instanceId": "test-01",
+ "instanceName": "Testinstanz",
+ "openRouterApiKey": "{{SecretProtector.Protect(ApiKeyPlain)}}",
+ "webServerPort": 8080,
+ "einUnbekanntesFeld": "muss erhalten bleiben"
+ }
+ """;
+ AtomicFile.WriteAllText(Path.Combine(_instanceDir, "InstanceSettings.json"), instanceSettings);
+
+ // Agent
+ var agentDir = Path.Combine(_instanceDir, "Agents", "Agent-Analyst");
+ Directory.CreateDirectory(Path.Combine(agentDir, "Workspace"));
+ Directory.CreateDirectory(Path.Combine(agentDir, "Logs"));
+
+ var agentSettings = $$"""
+ {
+ "agentId": "analyst",
+ "displayName": "Analyst",
+ "model": "anthropic/claude-sonnet-4-5",
+ "tools": {
+ "Mail": {
+ "smtpHost": "smtp.example.com",
+ "password": "{{SecretProtector.Protect(MailPasswordPlain)}}"
+ }
+ }
+ }
+ """;
+ AtomicFile.WriteAllText(Path.Combine(agentDir, "AgentSettings.json"), agentSettings);
+ AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"), "# Identity\nDer Analyst.");
+ AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"), "# Soul\nGründlich und knapp.");
+ AtomicFile.WriteAllText(Path.Combine(agentDir, "ChatHistory.json"), """[{"role":"user"}]""");
+ AtomicFile.WriteAllText(Path.Combine(agentDir, "Workspace", "bericht.md"), "# Bericht\nInhalt.");
+ AtomicFile.WriteAllText(Path.Combine(agentDir, "Logs", "lauf.log"), "viele Zeilen Protokoll");
+
+ // Datenbank mit einer Erinnerung — der wertvollste Teil.
+ var storage = new SqliteStorage(Path.Combine(_instanceDir, "state.db"));
+ var memory = new SqliteMemoryRepository(storage);
+ await memory.RememberAsync(new MemoryEntry
+ {
+ Scope = MemoryScope.Agent,
+ OwnerId = "analyst",
+ Subject = "NVDA",
+ Content = "Muss die Sicherung überstehen",
+ Key = "kernaussage",
+ CreatedBy = "analyst"
+ }, default);
+
+ // Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft.
+ Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
+ }
+
+ private string ZipPath => Path.Combine(_root, "sicherung.zip");
+ private string RestoreDir => Path.Combine(_root, "wiederhergestellt");
+
+ // ═══════════════════════════════════════════════════════════
+ // Der Rundlauf
+ // ═══════════════════════════════════════════════════════════
+
+ [Fact]
+ public async Task Der_vollstaendige_Rundlauf_stellt_alles_wieder_her()
+ {
+ await BuildInstanceAsync();
+
+ await _service.CreateAsync(_instanceDir, ZipPath,
+ new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" });
+
+ var result = await _service.RestoreAsync(ZipPath, RestoreDir,
+ new RestoreOptions { Passphrase = "geheim" });
+
+ result.Written.ShouldNotBeEmpty();
+
+ // Persönlichkeit
+ File.ReadAllText(Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "Identity.md"))
+ .ShouldContain("Der Analyst");
+ File.ReadAllText(Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "Soul.md"))
+ .ShouldContain("Gründlich");
+
+ // Arbeitsstand
+ File.ReadAllText(Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "Workspace", "bericht.md"))
+ .ShouldContain("Inhalt");
+
+ // Datenbank samt Gedächtnis
+ File.Exists(Path.Combine(RestoreDir, "state.db")).ShouldBeTrue();
+
+ var memory = new SqliteMemoryRepository(new SqliteStorage(Path.Combine(RestoreDir, "state.db")));
+ var recalled = await memory.RecallAsync(
+ new MemoryQuery { Scope = MemoryScope.Agent, OwnerId = "analyst" }, default);
+
+ recalled.ShouldHaveSingleItem();
+ recalled[0].Content.ShouldBe("Muss die Sicherung überstehen");
+ }
+
+ [Fact]
+ public async Task Zugangsdaten_sind_nach_dem_Wiederherstellen_wieder_nutzbar()
+ {
+ // Der eigentliche Zweck der Passphrase: Ein Backup wird gebraucht, wenn der
+ // Rechner defekt ist — DPAPI-geschützte Werte wären dann unlesbar.
+ await BuildInstanceAsync();
+
+ await _service.CreateAsync(_instanceDir, ZipPath,
+ new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" });
+ await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "geheim" });
+
+ var settings = JsonDocument.Parse(
+ File.ReadAllText(Path.Combine(RestoreDir, "InstanceSettings.json")));
+
+ var stored = settings.RootElement.GetProperty("openRouterApiKey").GetString();
+
+ SecretProtector.IsProtected(stored).ShouldBeTrue("wieder mit DPAPI geschützt");
+ SecretProtector.Unprotect(stored).ShouldBe(ApiKeyPlain);
+ }
+
+ [Fact]
+ public async Task Auch_verschachtelte_Zugangsdaten_in_Tool_Konfigurationen_kommen_zurueck()
+ {
+ await BuildInstanceAsync();
+
+ await _service.CreateAsync(_instanceDir, ZipPath,
+ new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" });
+ await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "geheim" });
+
+ var agent = JsonDocument.Parse(File.ReadAllText(
+ Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "AgentSettings.json")));
+
+ var password = agent.RootElement
+ .GetProperty("tools").GetProperty("Mail").GetProperty("password").GetString();
+
+ SecretProtector.Unprotect(password).ShouldBe(MailPasswordPlain);
+ }
+
+ [Fact]
+ public async Task Unbekannte_Felder_ueberstehen_den_Rundlauf()
+ {
+ // Eine Sicherung darf nichts wegwerfen, nur weil eine Programmfassung ein
+ // Feld nicht kennt.
+ await BuildInstanceAsync();
+
+ await _service.CreateAsync(_instanceDir, ZipPath,
+ new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" });
+ await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "geheim" });
+
+ File.ReadAllText(Path.Combine(RestoreDir, "InstanceSettings.json"))
+ .ShouldContain("einUnbekanntesFeld");
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // Zugangsdaten weglassen
+ // ═══════════════════════════════════════════════════════════
+
+ [Fact]
+ public async Task Ohne_Zugangsdaten_enthaelt_das_Archiv_keine_Geheimnisse()
+ {
+ await BuildInstanceAsync();
+
+ await _service.CreateAsync(_instanceDir, ZipPath,
+ new BackupOptions { Secrets = SecretMode.Exclude });
+
+ // Das gesamte Archiv im Klartext durchsuchen.
+ var inhalt = ReadWholeArchive(ZipPath);
+
+ inhalt.ShouldNotContain(ApiKeyPlain);
+ inhalt.ShouldNotContain(MailPasswordPlain);
+ }
+
+ [Fact]
+ public async Task Eine_Sicherung_mit_Passphrase_zeigt_die_Geheimnisse_nicht_im_Klartext()
+ {
+ await BuildInstanceAsync();
+
+ await _service.CreateAsync(_instanceDir, ZipPath,
+ new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" });
+
+ var inhalt = ReadWholeArchive(ZipPath);
+
+ inhalt.ShouldNotContain(ApiKeyPlain);
+ inhalt.ShouldNotContain(MailPasswordPlain);
+ inhalt.ShouldContain("pbe:v1:");
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // Manifest und Fehlerfälle
+ // ═══════════════════════════════════════════════════════════
+
+ [Fact]
+ public async Task Das_Manifest_beschreibt_die_Sicherung()
+ {
+ await BuildInstanceAsync();
+
+ var result = await _service.CreateAsync(_instanceDir, ZipPath,
+ new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" });
+
+ var manifest = await _service.InspectAsync(ZipPath);
+
+ manifest.InstanceId.ShouldBe("test-01");
+ manifest.InstanceName.ShouldBe("Testinstanz");
+ manifest.HasSecrets.ShouldBeTrue();
+ manifest.SecretCount.ShouldBe(2, "API-Schlüssel und Mail-Passwort");
+ manifest.Files.ShouldNotBeEmpty();
+ result.SizeBytes.ShouldBeGreaterThan(0);
+ }
+
+ [Fact]
+ public async Task Ohne_Passphrase_wird_nicht_wiederhergestellt()
+ {
+ await BuildInstanceAsync();
+ await _service.CreateAsync(_instanceDir, ZipPath,
+ new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "geheim" });
+
+ var ex = await Should.ThrowAsync(
+ () => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions()));
+
+ ex.Message.ShouldContain("Passphrase");
+ }
+
+ [Fact]
+ public async Task Eine_falsche_Passphrase_wird_erkannt()
+ {
+ await BuildInstanceAsync();
+ await _service.CreateAsync(_instanceDir, ZipPath,
+ new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "richtig" });
+
+ await Should.ThrowAsync(
+ () => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "falsch" }));
+ }
+
+ [Fact]
+ public async Task Ein_veraendertes_Archiv_faellt_auf()
+ {
+ await BuildInstanceAsync();
+ await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions());
+
+ // Eine Datei im Archiv nachträglich verändern.
+ using (var archive = ZipFile.Open(ZipPath, ZipArchiveMode.Update))
+ {
+ var entry = archive.Entries.First(e => e.FullName.EndsWith("Identity.md"));
+ using var stream = entry.Open();
+ stream.SetLength(0);
+ using var writer = new StreamWriter(stream);
+ writer.Write("manipuliert");
+ }
+
+ var ex = await Should.ThrowAsync(
+ () => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions()));
+
+ ex.Message.ShouldContain("Prüfsumme");
+ }
+
+ [Fact]
+ public async Task Ein_Archiv_ohne_Manifest_wird_abgelehnt()
+ {
+ var fremd = Path.Combine(_root, "fremd.zip");
+ var quelle = Path.Combine(_root, "quelle");
+ Directory.CreateDirectory(quelle);
+ File.WriteAllText(Path.Combine(quelle, "irgendwas.txt"), "Inhalt");
+ ZipFile.CreateFromDirectory(quelle, fremd);
+
+ var ex = await Should.ThrowAsync(() => _service.InspectAsync(fremd));
+
+ ex.Message.ShouldContain("Manifest");
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // Vorschau und Überschreiben
+ // ═══════════════════════════════════════════════════════════
+
+ [Fact]
+ public async Task Die_Vorschau_schreibt_nichts()
+ {
+ await BuildInstanceAsync();
+ await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions());
+
+ var result = await _service.RestoreAsync(ZipPath, RestoreDir,
+ new RestoreOptions { DryRun = true });
+
+ result.Written.ShouldBeEmpty();
+ Directory.Exists(RestoreDir).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Vorhandene_Dateien_werden_ohne_Zustimmung_nicht_ueberschrieben()
+ {
+ await BuildInstanceAsync();
+ await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions());
+
+ Directory.CreateDirectory(RestoreDir);
+ var vorhanden = Path.Combine(RestoreDir, "InstanceSettings.json");
+ AtomicFile.WriteAllText(vorhanden, """{"wichtig":"nicht verlieren"}""");
+
+ var result = await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions());
+
+ result.HasConflicts.ShouldBeTrue();
+ result.Skipped.ShouldContain("InstanceSettings.json");
+ File.ReadAllText(vorhanden).ShouldContain("nicht verlieren");
+ }
+
+ [Fact]
+ public async Task Mit_Zustimmung_wird_ueberschrieben()
+ {
+ await BuildInstanceAsync();
+ await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions());
+
+ Directory.CreateDirectory(RestoreDir);
+ var vorhanden = Path.Combine(RestoreDir, "InstanceSettings.json");
+ AtomicFile.WriteAllText(vorhanden, """{"alt":true}""");
+
+ await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Overwrite = true });
+
+ File.ReadAllText(vorhanden).ShouldContain("Testinstanz");
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // Umfang
+ // ═══════════════════════════════════════════════════════════
+
+ [Fact]
+ public async Task Protokolle_bleiben_standardmaessig_aussen_vor()
+ {
+ await BuildInstanceAsync();
+ await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions());
+
+ var manifest = await _service.InspectAsync(ZipPath);
+
+ manifest.Files.ShouldNotContain(f => f.Path.Contains("Logs/"));
+ }
+
+ [Fact]
+ public async Task Protokolle_lassen_sich_einschliessen()
+ {
+ await BuildInstanceAsync();
+ await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions { IncludeLogs = true });
+
+ var manifest = await _service.InspectAsync(ZipPath);
+
+ manifest.Files.ShouldContain(f => f.Path.Contains("Logs/"));
+ }
+
+ [Fact]
+ public async Task Die_WAL_Begleitdateien_landen_nicht_im_Archiv()
+ {
+ // VACUUM INTO arbeitet sie bereits ein — mitzusichern wäre irreführend.
+ await BuildInstanceAsync();
+ await _service.CreateAsync(_instanceDir, ZipPath, new BackupOptions());
+
+ var manifest = await _service.InspectAsync(ZipPath);
+
+ manifest.Files.ShouldNotContain(f => f.Path.EndsWith("-wal") || f.Path.EndsWith("-shm"));
+ manifest.Files.ShouldContain(f => f.Path == "state.db");
+ }
+
+ [Fact]
+ public async Task Ein_fehlendes_Instanzverzeichnis_wird_gemeldet()
+ {
+ var ex = await Should.ThrowAsync(
+ () => _service.CreateAsync(Path.Combine(_root, "gibtsnicht"), ZipPath, new BackupOptions()));
+
+ ex.Message.ShouldContain("nicht gefunden");
+ }
+
+ [Fact]
+ public async Task Passphrase_Modus_ohne_Passphrase_wird_abgelehnt()
+ {
+ await BuildInstanceAsync();
+
+ await Should.ThrowAsync(() => _service.CreateAsync(
+ _instanceDir, ZipPath, new BackupOptions { Secrets = SecretMode.Passphrase }));
+ }
+
+ // ─── Helfer ───
+
+ private static string ReadWholeArchive(string zipPath)
+ {
+ using var archive = ZipFile.OpenRead(zipPath);
+ var sb = new System.Text.StringBuilder();
+
+ foreach (var entry in archive.Entries)
+ {
+ using var stream = entry.Open();
+ using var buffer = new MemoryStream();
+ stream.CopyTo(buffer);
+ sb.Append(System.Text.Encoding.UTF8.GetString(buffer.ToArray()));
+ }
+
+ return sb.ToString();
+ }
+}