using System.Security.Cryptography; using System.Text; using System.Text.Json; using ClawdDotNet.Core.Backup; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tests.Infrastructure; using Shouldly; namespace ClawdDotNet.Core.Tests.Backup; /// /// The backup, encrypted with the license key. /// /// What this is about. A backup should survive a change of machine /// without a passphrase having to be stored or typed in. The license key /// is there anyway. And an archive on an FTP server or a USB stick should be of no use /// to anyone who merely finds it. /// /// Runs in : the change of machine is reproduced via the /// static key store. /// [Collection(SecretKeyCollection.Name)] public sealed class LicenseBackupTests : IDisposable { private const string Lizenz = "CLAWD-ABCD-1234-EFGH-5678"; private const string ApiKey = "sk-or-v1-streng-geheim-98765"; private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "licbak-" + Guid.NewGuid().ToString("N")[..8]); private string Datei(string name) => Path.Combine(_root, name); public LicenseBackupTests() { Directory.CreateDirectory(_root); SecretKeyStore.UseDirectory(Datei("maschine-a")); } public void Dispose() { SecretKeyStore.UseDirectory(null); ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root); try { Directory.Delete(_root, recursive: true); } catch { /* Cleaning up is secondary */ } } private string Source(byte[] content) { var path = Datei("quelle.bin"); File.WriteAllBytes(path, content); return path; } // ─── The container ─── /// /// More than one chunk: the chunking is the part where an error swallows data /// without it being noticed. /// [Fact] public void An_archive_spanning_several_chunks_comes_back_unchanged() { var content = RandomNumberGenerator.GetBytes(2_600_000); LicenseBackupCipher.Encrypt(Source(content), "{}", Datei("a.clawdbak"), Lizenz); LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz); File.ReadAllBytes(Datei("zurueck.bin")).ShouldBe(content); } [Fact] public void An_empty_archive_comes_back_too() { LicenseBackupCipher.Encrypt(Source([]), "{}", Datei("a.clawdbak"), Lizenz); LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz); File.ReadAllBytes(Datei("zurueck.bin")).ShouldBeEmpty(); } /// /// A different key is recognized at the header — before anything is decrypted and /// above all before an instance is stopped for it. /// [Fact] public void A_foreign_key_is_recognised_before_anything_is_written() { LicenseBackupCipher.Encrypt(Source([1, 2, 3]), "{}", Datei("a.clawdbak"), Lizenz); LicenseBackupCipher.MatchesKey(Datei("a.clawdbak"), "ANDERE-LIZENZ").ShouldBeFalse(); LicenseBackupCipher.MatchesKey(Datei("a.clawdbak"), Lizenz).ShouldBeTrue(); Should.Throw(() => LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), "ANDERE-LIZENZ")); File.Exists(Datei("zurueck.bin")).ShouldBeFalse(); } /// /// In the restore dialog a human types in a foreign key. A /// space or a lowercase letter must not decide about "lost" there. /// [Fact] public void Spelling_and_spaces_of_the_key_do_not_matter() { LicenseBackupCipher.Encrypt(Source([4, 5, 6]), "{}", Datei("a.clawdbak"), " clawd-abcd-1234 -efgh-5678 "); LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz); File.ReadAllBytes(Datei("zurueck.bin")).ShouldBe([4, 5, 6]); } [Fact] public void The_manifest_is_readable_without_decrypting_the_archive() { LicenseBackupCipher.Encrypt(Source(RandomNumberGenerator.GetBytes(3_000_000)), """{"instanceName":"Alpha"}""", Datei("a.clawdbak"), Lizenz); LicenseBackupCipher.ReadManifest(Datei("a.clawdbak"), Lizenz).ShouldContain("Alpha"); } /// /// If a whole chunk is missing at the end, that must be noticed. Without the end marker you would consider the rest /// complete and get a ZIP that aborts somewhere when unpacking. /// [Fact] public void A_missing_last_chunk_is_noticed() { var content = RandomNumberGenerator.GetBytes(1024 * 1024 + 500); const string manifest = "{}"; LicenseBackupCipher.Encrypt(Source(content), manifest, Datei("a.clawdbak"), Lizenz); // Header 8+1+16, manifest 12+4+16+len, first chunk 12+4+16+1 MiB - cut after that. var upToFirstChunk = 25 + (32 + Encoding.UTF8.GetByteCount(manifest)) + (32 + 1024 * 1024); var full = File.ReadAllBytes(Datei("a.clawdbak")); File.WriteAllBytes(Datei("a.clawdbak"), full[..upToFirstChunk]); Should.Throw(() => LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz)); } [Fact] public void A_change_in_the_middle_of_the_archive_is_noticed() { LicenseBackupCipher.Encrypt(Source(RandomNumberGenerator.GetBytes(200_000)), "{}", Datei("a.clawdbak"), Lizenz); var bytes = File.ReadAllBytes(Datei("a.clawdbak")); bytes[bytes.Length / 2] ^= 0x01; File.WriteAllBytes(Datei("a.clawdbak"), bytes); Should.Throw(() => LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz)); } // ─── The backup as a whole ─── private string Instance() { var path = Datei("Instance-Alpha"); Directory.CreateDirectory(path); AtomicFile.WriteAllText(Path.Combine(path, "InstanceSettings.json"), $$""" { "instanceId": "alpha-1", "instanceName": "Alpha", "openRouterApiKey": "{{SecretProtector.Protect(ApiKey)}}" } """); return path; } private async Task BackupAsync() { var ziel = Datei("backup_Alpha.clawdbak"); await new BackupService().CreateAsync(Instance(), ziel, new BackupOptions { Secrets = SecretMode.License, LicenseKey = Lizenz }); return ziel; } /// /// The case this is about: back up on machine A, restore on machine B, and /// the API key is readable afterwards — without a passphrase, solely with the /// license key. /// [Fact] public async Task A_backup_survives_the_machine_change() { var backup = await BackupAsync(); SecretKeyStore.UseDirectory(Datei("maschine-b")); var ziel = Datei("Wiederhergestellt"); await new BackupService().RestoreAsync(backup, ziel, new RestoreOptions { LicenseKey = Lizenz, Overwrite = true }); using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(ziel, "InstanceSettings.json"))); var value = doc.RootElement.GetProperty("openRouterApiKey").GetString(); SecretProtector.Unprotect(value).ShouldBe(ApiKey); } /// /// Nothing is in plain text in the file — neither the key nor that there is /// one at all. /// [Fact] public async Task The_backup_reveals_no_plaintext() { var backup = await BackupAsync(); LicenseBackupCipher.IsEncrypted(backup).ShouldBeTrue(); var raw = Encoding.Latin1.GetString(File.ReadAllBytes(backup)); raw.ShouldNotContain(ApiKey); raw.ShouldNotContain("openRouterApiKey"); raw.ShouldNotContain("InstanceSettings"); } /// /// With a foreign key nothing is written — not half, not partially. /// [Fact] public async Task With_a_foreign_key_the_target_stays_untouched() { var backup = await BackupAsync(); var ziel = Datei("Ziel"); Directory.CreateDirectory(ziel); await Should.ThrowAsync(() => new BackupService().RestoreAsync(backup, ziel, new RestoreOptions { LicenseKey = "ANDERE-LIZENZ", Overwrite = true })); Directory.EnumerateFileSystemEntries(ziel).ShouldBeEmpty(); } [Fact] public async Task The_overview_needs_the_matching_key() { var backup = await BackupAsync(); (await new BackupService().InspectAsync(backup, licenseKey: Lizenz)) .InstanceName.ShouldBe("Alpha"); await Should.ThrowAsync(() => new BackupService().InspectAsync(backup, licenseKey: "ANDERE-LIZENZ")); } }