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>
This commit is contained in:
Richard
2026-09-11 11:03:25 +02:00
co-authored by Claude Opus 5
parent 99f8a89fd0
commit fb185ca84d
17 changed files with 2140 additions and 389 deletions
@@ -0,0 +1,254 @@
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"));
}
}
@@ -0,0 +1,283 @@
using System.Text.Json;
using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// Die Sicherungen im Manager (#530): verschlüsselt mit dem Lizenzschlüssel, einmal am
/// Tag, aufgeräumt, auf Wunsch hochgeladen.
/// </summary>
public sealed class ManagerBackupsTests : IAsyncDisposable
{
private const string Lizenz = "CLAWD-TEST-0000-1111-2222";
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "mbak-" + Guid.NewGuid().ToString("N")[..8]);
private readonly InstanceDirectoryManager _verzeichnisse;
private readonly ManagerSettingsStore _settings;
private readonly FakeUploader _upload = new();
private readonly FesteUhr _uhr = new(new DateTimeOffset(2026, 9, 11, 2, 0, 0, TimeSpan.Zero));
public ManagerBackupsTests()
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
_settings = new ManagerSettingsStore(_root);
_settings.Settings.LicenseKey = Lizenz;
}
public async ValueTask DisposeAsync()
{
await Task.CompletedTask;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
private string Instanz(string name, bool automatisch = true, string uhrzeit = "03:00", int behalten = 14)
{
var pfad = _verzeichnisse.CreateInstance(name);
var datei = Path.Combine(pfad, "InstanceSettings.json");
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(datei))!;
config.AutoBackupEnabled = automatisch;
config.AutoBackupTime = uhrzeit;
config.BackupKeepCount = behalten;
File.WriteAllText(datei, JsonSerializer.Serialize(config));
return pfad;
}
private async Task<ManagerBackups> DienstAsync(IBackupUploader? upload = null)
{
var register = new InstanceRegistry(
_verzeichnisse, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
SupervisionOptions.Default);
await register.RefreshAsync();
return new ManagerBackups(
_settings, register, upload ?? _upload, NullLogger.Instance, _uhr);
}
private static string[] Sicherungen(string pfad)
{
var ordner = Path.Combine(pfad, "Backups");
return Directory.Exists(ordner) ? Directory.GetFiles(ordner) : [];
}
// ─── Namen ───
/// <summary>
/// Ein Präfix ist keine Grenze: Die Archive von <c>Test_2</c> gehören nicht zu
/// <c>Test</c> — sonst wirft das Aufräumen der einen die Sicherungen der anderen weg.
/// </summary>
[Fact]
public void Ineinanderliegende_Instanznamen_werden_nicht_verwechselt()
{
var eigene = BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 11, 3, 0, 0));
var fremde = BackupNaming.FileNameFor("Test_2", new DateTime(2026, 9, 11, 3, 0, 0));
BackupNaming.BelongsTo(eigene, "Test").ShouldBeTrue();
BackupNaming.BelongsTo(fremde, "Test").ShouldBeFalse();
BackupNaming.BelongsTo(fremde, "Test_2").ShouldBeTrue();
}
[Theory]
[InlineData("backup_Alpha_2026-09-11_0300.zip")] // taegliche Sicherung der Instanz
[InlineData("backup_Alpha_20260911_030000.zip")] // erste Verwaltungsansicht
[InlineData("backup_Alpha_2026-09-11_030000.clawdbak")] // Manager
public void Aeltere_Schreibweisen_werden_erkannt(string name)
{
BackupNaming.TimestampOf(name, "Alpha").ShouldBe(new DateTime(2026, 9, 11, 3, 0, 0));
}
[Theory]
[InlineData("notizen.txt")]
[InlineData("backup_Alpha_kaputt.zip")]
[InlineData("backup_Alpha_2026-09-11_0300.zip.part")]
public void Fremde_Dateien_gehoeren_niemandem(string name)
{
BackupNaming.BelongsTo(name, "Alpha").ShouldBeFalse();
}
// ─── Sichern ───
[Fact]
public async Task Ohne_Lizenzschluessel_wird_keine_Sicherung_angelegt()
{
var pfad = Instanz("Alpha");
_settings.Settings.LicenseKey = "";
await using var dienst = await DienstAsync();
var instanz = (await RegisterAsync()).Single();
await Should.ThrowAsync<BackupException>(() =>
dienst.CreateAsync(instanz, false, true, CancellationToken.None));
Sicherungen(pfad).ShouldBeEmpty("Unverschlüsselt wird nichts abgelegt — auch nicht ersatzweise.");
}
[Fact]
public async Task Eine_Sicherung_ist_verschluesselt_und_wird_hochgeladen()
{
var pfad = Instanz("Alpha");
await using var dienst = await DienstAsync();
var instanz = (await RegisterAsync()).Single();
var ergebnis = await dienst.CreateAsync(instanz, false, true, CancellationToken.None);
LicenseBackupCipher.IsEncrypted(ergebnis.Path).ShouldBeTrue();
LicenseBackupCipher.MatchesKey(ergebnis.Path, Lizenz).ShouldBeTrue();
ergebnis.Uploaded.ShouldBeTrue();
_upload.Hochgeladen.ShouldBe([ergebnis.Path]);
}
/// <summary>
/// Ein gescheiterter Upload ist ein Befund, aber kein Grund, die Sicherung für
/// misslungen zu erklären — sie liegt ja lokal.
/// </summary>
[Fact]
public async Task Ein_gescheiterter_Upload_laesst_die_Sicherung_stehen()
{
Instanz("Alpha");
_upload.Fehler = new IOException("Server nicht erreichbar");
await using var dienst = await DienstAsync();
var instanz = (await RegisterAsync()).Single();
var ergebnis = await dienst.CreateAsync(instanz, false, true, CancellationToken.None);
File.Exists(ergebnis.Path).ShouldBeTrue();
ergebnis.Uploaded.ShouldBeFalse();
ergebnis.UploadProblem.ShouldBe("Server nicht erreichbar");
}
[Fact]
public async Task Aufgeraeumt_wird_nur_die_eigene_Instanz()
{
var ordner = Path.Combine(_root, "ablage");
Directory.CreateDirectory(ordner);
for (var tag = 1; tag <= 5; tag++)
{
var zeit = new DateTime(2026, 9, tag, 3, 0, 0);
File.WriteAllText(Path.Combine(ordner, BackupNaming.FileNameFor("Test", zeit)), "");
File.WriteAllText(Path.Combine(ordner, BackupNaming.FileNameFor("Test_2", zeit)), "");
}
await using var dienst = await DienstAsync();
dienst.Rotate(ordner, "Test", keep: 2);
var namen = Directory.GetFiles(ordner).Select(Path.GetFileName).ToList();
namen.Count(n => BackupNaming.BelongsTo(n!, "Test")).ShouldBe(2);
namen.Count(n => BackupNaming.BelongsTo(n!, "Test_2")).ShouldBe(5, "Die andere Instanz bleibt unberührt.");
namen.ShouldContain(BackupNaming.FileNameFor("Test", new DateTime(2026, 9, 5, 3, 0, 0)));
}
// ─── Der tägliche Takt ───
[Fact]
public async Task Vor_der_Uhrzeit_passiert_nichts()
{
var pfad = Instanz("Alpha", uhrzeit: "03:00");
await using var dienst = await DienstAsync();
await dienst.TickAsync(); // 02:00
Sicherungen(pfad).ShouldBeEmpty();
}
[Fact]
public async Task Einmal_am_Tag_und_nicht_oefter()
{
var pfad = Instanz("Alpha", uhrzeit: "03:00");
await using var dienst = await DienstAsync();
_uhr.Jetzt = _uhr.Jetzt.AddHours(1.5); // 03:30
await dienst.TickAsync();
_uhr.Jetzt = _uhr.Jetzt.AddMinutes(30); // 04:00
await dienst.TickAsync();
Sicherungen(pfad).Length.ShouldBe(1);
}
/// <summary>
/// Auch über einen Neustart des Managers hinweg. Nur im Speicher zu zählen hieße: Wer
/// nach der Sicherungszeit neu startet, bekommt eine zweite Sicherung.
/// </summary>
[Fact]
public async Task Ein_Neustart_des_Managers_erzeugt_keine_zweite_Sicherung()
{
var pfad = Instanz("Alpha", uhrzeit: "03:00");
_uhr.Jetzt = _uhr.Jetzt.AddHours(1.5);
await using (var erster = await DienstAsync())
await erster.TickAsync();
_uhr.Jetzt = _uhr.Jetzt.AddHours(1);
await using (var zweiter = await DienstAsync())
await zweiter.TickAsync();
Sicherungen(pfad).Length.ShouldBe(1);
}
[Fact]
public async Task Ohne_Schalter_keine_automatische_Sicherung()
{
var pfad = Instanz("Alpha", automatisch: false);
_uhr.Jetzt = _uhr.Jetzt.AddHours(5);
await using var dienst = await DienstAsync();
await dienst.TickAsync();
Sicherungen(pfad).ShouldBeEmpty();
}
// ─── Hilfen ───
private async Task<IReadOnlyList<ManagedInstance>> RegisterAsync()
{
var register = new InstanceRegistry(
_verzeichnisse, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance,
SupervisionOptions.Default);
await register.RefreshAsync();
return register.Instances;
}
private sealed class FakeUploader : IBackupUploader
{
public List<string> Hochgeladen { get; } = [];
public Exception? Fehler { get; set; }
public bool IsConfigured => true;
public Task UploadAsync(string localPath, string instanceName, int keepCount, CancellationToken ct)
{
if (Fehler is not null) throw Fehler;
Hochgeladen.Add(localPath);
return Task.CompletedTask;
}
public Task<string?> TestAsync(CancellationToken ct) => Task.FromResult<string?>(null);
}
/// <summary>Eine Uhr in UTC, damit „lokal" im Test überall dasselbe heißt.</summary>
private sealed class FesteUhr(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Jetzt { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Jetzt;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
}