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; /// /// Die Sicherung, verschlüsselt mit dem Lizenzschlüssel. /// /// Worum es geht. Eine Sicherung soll einen Maschinenwechsel überstehen, /// ohne dass eine Passphrase gespeichert oder eingetippt werden muss. Der Lizenzschlüssel /// ist ohnehin da. Und ein Archiv auf einem FTP-Server oder einem USB-Stick soll niemandem /// nützen, der es nur findet. /// /// Läuft in : Der Maschinenwechsel wird über den /// statischen Schlüsselspeicher nachgestellt. /// [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 { /* Aufräumen ist Nebensache */ } } private string Quelle(byte[] inhalt) { var pfad = Datei("quelle.bin"); File.WriteAllBytes(pfad, inhalt); return pfad; } // ─── Der Behälter ─── /// /// Mehr als ein Stück: Die Stückelung ist der Teil, an dem ein Fehler Daten /// verschluckt, ohne dass es auffällt. /// [Fact] public void Ein_Archiv_ueber_mehrere_Stuecke_kommt_unveraendert_zurueck() { var inhalt = RandomNumberGenerator.GetBytes(2_600_000); LicenseBackupCipher.Encrypt(Quelle(inhalt), "{}", Datei("a.clawdbak"), Lizenz); LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz); File.ReadAllBytes(Datei("zurueck.bin")).ShouldBe(inhalt); } [Fact] public void Auch_ein_leeres_Archiv_kommt_zurueck() { LicenseBackupCipher.Encrypt(Quelle([]), "{}", Datei("a.clawdbak"), Lizenz); LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz); File.ReadAllBytes(Datei("zurueck.bin")).ShouldBeEmpty(); } /// /// Ein anderer Schlüssel wird am Kopf erkannt — bevor irgendetwas entschlüsselt und /// vor allem bevor eine Instanz dafür angehalten wird. /// [Fact] public void Ein_fremder_Schluessel_wird_erkannt_bevor_etwas_geschrieben_wird() { LicenseBackupCipher.Encrypt(Quelle([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(); } /// /// Im Wiederherstellungsdialog tippt ein Mensch einen fremden Schlüssel ab. Ein /// Leerzeichen oder ein kleiner Buchstabe dürfen dort nicht über „verloren" entscheiden. /// [Fact] public void Schreibweise_und_Leerzeichen_des_Schluessels_spielen_keine_Rolle() { LicenseBackupCipher.Encrypt(Quelle([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 Das_Manifest_ist_lesbar_ohne_das_Archiv_zu_entschluesseln() { LicenseBackupCipher.Encrypt(Quelle(RandomNumberGenerator.GetBytes(3_000_000)), """{"instanceName":"Alpha"}""", Datei("a.clawdbak"), Lizenz); LicenseBackupCipher.ReadManifest(Datei("a.clawdbak"), Lizenz).ShouldContain("Alpha"); } /// /// Fehlt hinten ein ganzes Stück, muss das auffallen. Ohne Endmarke hielte man den Rest /// für vollständig und bekäme ein ZIP, das beim Auspacken irgendwo abbricht. /// [Fact] public void Ein_fehlendes_letztes_Stueck_faellt_auf() { var inhalt = RandomNumberGenerator.GetBytes(1024 * 1024 + 500); const string manifest = "{}"; LicenseBackupCipher.Encrypt(Quelle(inhalt), manifest, Datei("a.clawdbak"), Lizenz); // Kopf 8+1+16, Manifest 12+4+16+len, erstes Stueck 12+4+16+1 MiB - danach kappen. var bisErstesStueck = 25 + (32 + Encoding.UTF8.GetByteCount(manifest)) + (32 + 1024 * 1024); var voll = File.ReadAllBytes(Datei("a.clawdbak")); File.WriteAllBytes(Datei("a.clawdbak"), voll[..bisErstesStueck]); Should.Throw(() => LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz)); } [Fact] public void Eine_Veraenderung_mitten_im_Archiv_faellt_auf() { LicenseBackupCipher.Encrypt(Quelle(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)); } // ─── Die Sicherung als Ganzes ─── private string Instanz() { var pfad = Datei("Instance-Alpha"); Directory.CreateDirectory(pfad); AtomicFile.WriteAllText(Path.Combine(pfad, "InstanceSettings.json"), $$""" { "instanceId": "alpha-1", "instanceName": "Alpha", "openRouterApiKey": "{{SecretProtector.Protect(ApiKey)}}" } """); return pfad; } private async Task SicherungAsync() { var ziel = Datei("backup_Alpha.clawdbak"); await new BackupService().CreateAsync(Instanz(), ziel, new BackupOptions { Secrets = SecretMode.License, LicenseKey = Lizenz }); return ziel; } /// /// Der Fall, um den es geht: Sichern auf Maschine A, zurückspielen auf Maschine B, und /// der API-Schlüssel ist danach lesbar — ohne Passphrase, allein mit dem /// Lizenzschlüssel. /// [Fact] public async Task Eine_Sicherung_ueberlebt_den_Maschinenwechsel() { var sicherung = await SicherungAsync(); SecretKeyStore.UseDirectory(Datei("maschine-b")); var ziel = Datei("Wiederhergestellt"); await new BackupService().RestoreAsync(sicherung, ziel, new RestoreOptions { LicenseKey = Lizenz, Overwrite = true }); using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(ziel, "InstanceSettings.json"))); var wert = doc.RootElement.GetProperty("openRouterApiKey").GetString(); SecretProtector.Unprotect(wert).ShouldBe(ApiKey); } /// /// In der Datei steht nichts im Klartext — weder der Schlüssel noch, dass es überhaupt /// einen gibt. /// [Fact] public async Task Die_Sicherung_verraet_keinen_Klartext() { var sicherung = await SicherungAsync(); LicenseBackupCipher.IsEncrypted(sicherung).ShouldBeTrue(); var roh = Encoding.Latin1.GetString(File.ReadAllBytes(sicherung)); roh.ShouldNotContain(ApiKey); roh.ShouldNotContain("openRouterApiKey"); roh.ShouldNotContain("InstanceSettings"); } /// /// Mit fremdem Schlüssel wird nichts geschrieben — nicht halb, nicht teilweise. /// [Fact] public async Task Mit_fremdem_Schluessel_bleibt_das_Ziel_unberuehrt() { var sicherung = await SicherungAsync(); var ziel = Datei("Ziel"); Directory.CreateDirectory(ziel); await Should.ThrowAsync(() => new BackupService().RestoreAsync(sicherung, ziel, new RestoreOptions { LicenseKey = "ANDERE-LIZENZ", Overwrite = true })); Directory.EnumerateFileSystemEntries(ziel).ShouldBeEmpty(); } [Fact] public async Task Die_Uebersicht_braucht_den_passenden_Schluessel() { var sicherung = await SicherungAsync(); (await new BackupService().InspectAsync(sicherung, licenseKey: Lizenz)) .InstanceName.ShouldBe("Alpha"); await Should.ThrowAsync(() => new BackupService().InspectAsync(sicherung, licenseKey: "ANDERE-LIZENZ")); } }