Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Backup/LicenseBackupTests.cs
T
RichardandClaude Opus 5 fb185ca84d Sicherungen mit dem Lizenzschluessel verschluesselt, optional per FTP (#530)
Eine Sicherung des Managers ist jetzt als Ganzes mit dem Lizenzschluessel
verschluesselt. Beim Zurueckspielen wird der eigene Schluessel probiert; passt er
nicht, fragt die Oberflaeche nach dem Lizenzschluessel der Quellinstallation.

Der Lizenzschluessel ist der eine Wert, den jede Installation ohnehin hat, und er
liegt im Manager, nicht neben den Archiven. Damit faellt der Grund weg, aus dem die
taegliche Sicherung bisher keine Zugangsdaten enthielt: Eine Passphrase haette
gespeichert werden muessen, und neben den Sicherungen waere sie wirkungslos
gewesen. Jetzt reisen die Zugangsdaten mit, und eine Sicherung uebersteht den
Maschinenwechsel ohne Passphrase.

LicenseBackupCipher: eigener Behaelter .clawdbak. Kopf mit Schluesselprobe, das
Manifest als eigener Abschnitt, das ZIP in 1-MiB-Stuecken mit AES-256-GCM und
Endmarke. PBKDF2 aus Lizenzschluessel und festem Salt, der Schluessel normiert.
Kein RAR (braucht das lizenzpflichtige Herstellerprogramm), kein ZIP mit Kennwort
(kann .NET nicht schreiben, ZipCrypto ist gebrochen, und mit abgeleitetem
Schluessel braechte 7-Zip-Vertraeglichkeit nichts).

Zurueckspielen: Die Schluesselprobe laeuft VOR dem Anhalten der Instanz. Passt
der Schluessel nicht, antwortet der Endpunkt mit 422 und dem Code fremde_lizenz,
und ein Fenster verlangt den Schluessel der Quelle.

ManagerBackups: Gefuehrte Instanzen sichern sich nicht mehr selbst, weil nur der
Manager den Schluessel hat; er liest ihre Einstellungen aus InstanceSettings.json.
Einmal am Tag auch ueber einen Neustart des Managers hinweg. Ohne Lizenzschluessel
wird nichts gesichert, auch nicht ersatzweise unverschluesselt.

FTP (FluentFTP, schon im Loesungsbaum): TLS wo angeboten, requireTls erzwingt es;
Upload unter .part-Namen und danach umbenannt; Aufraeumen auf dem Server je
Instanz; ein gescheiterter Upload laesst die lokale Sicherung stehen. Das
Kennwort liegt verschluesselt in manager.json und geht nie an die Oberflaeche.

Welche Datei zu welcher Instanz gehoert, entscheidet ein Zeitstempel nach dem
Namen und nicht das Praefix - Test und Test_2 werden nicht mehr verwechselt.

Grenze, bewusst und dokumentiert (Konzept 9.10): Wer Lizenzschluessel und Archiv
hat, hat die Zugangsdaten. Der Schluessel ist dem Hersteller bekannt und gilt fuer
alle Instanzen einer Installation.

Nicht ueber die Oberflaeche abgenommen: Fenster fuer die fremde Lizenz und FTP
gegen einen echten Server stehen als Block H in #537.

860 Tests gruen, 0 Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:03:25 +02:00

255 lines
9.1 KiB
C#

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;
/// <summary>
/// Die Sicherung, verschlüsselt mit dem Lizenzschlüssel.
///
/// <para><b>Worum es geht.</b> 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.</para>
///
/// <para>Läuft in <see cref="SecretKeyCollection"/>: Der Maschinenwechsel wird über den
/// statischen Schlüsselspeicher nachgestellt.</para>
/// </summary>
[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);
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
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 ───
/// <summary>
/// Mehr als ein Stück: Die Stückelung ist der Teil, an dem ein Fehler Daten
/// verschluckt, ohne dass es auffällt.
/// </summary>
[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();
}
/// <summary>
/// Ein anderer Schlüssel wird am Kopf erkannt — bevor irgendetwas entschlüsselt und
/// vor allem bevor eine Instanz dafür angehalten wird.
/// </summary>
[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<BackupKeyMismatchException>(() =>
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), "ANDERE-LIZENZ"));
File.Exists(Datei("zurueck.bin")).ShouldBeFalse();
}
/// <summary>
/// Im Wiederherstellungsdialog tippt ein Mensch einen fremden Schlüssel ab. Ein
/// Leerzeichen oder ein kleiner Buchstabe dürfen dort nicht über „verloren" entscheiden.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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<BackupException>(() =>
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<BackupException>(() =>
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<string> SicherungAsync()
{
var ziel = Datei("backup_Alpha.clawdbak");
await new BackupService().CreateAsync(Instanz(), ziel, new BackupOptions
{
Secrets = SecretMode.License,
LicenseKey = Lizenz
});
return ziel;
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// In der Datei steht nichts im Klartext — weder der Schlüssel noch, dass es überhaupt
/// einen gibt.
/// </summary>
[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");
}
/// <summary>
/// Mit fremdem Schlüssel wird nichts geschrieben — nicht halb, nicht teilweise.
/// </summary>
[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<BackupKeyMismatchException>(() =>
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<BackupKeyMismatchException>(() =>
new BackupService().InspectAsync(sicherung, licenseKey: "ANDERE-LIZENZ"));
}
}