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:
@@ -48,6 +48,9 @@ public sealed class BackupService
|
||||
if (options.Secrets == SecretMode.Passphrase && string.IsNullOrEmpty(options.Passphrase))
|
||||
throw new BackupException("Für den Schutz der Zugangsdaten wird eine Passphrase benötigt.");
|
||||
|
||||
if (options.Secrets == SecretMode.License && string.IsNullOrWhiteSpace(options.LicenseKey))
|
||||
throw new BackupException("Ohne Lizenzschlüssel lässt sich keine verschlüsselte Sicherung anlegen.");
|
||||
|
||||
var staging = Path.Combine(Path.GetTempPath(), "clawd-backup-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(staging);
|
||||
|
||||
@@ -111,8 +114,32 @@ public sealed class BackupService
|
||||
if (File.Exists(targetZipPath))
|
||||
File.Delete(targetZipPath);
|
||||
|
||||
ZipFile.CreateFromDirectory(staging, targetZipPath, CompressionLevel.Optimal,
|
||||
includeBaseDirectory: false);
|
||||
if (options.Secrets != SecretMode.License)
|
||||
{
|
||||
ZipFile.CreateFromDirectory(staging, targetZipPath, CompressionLevel.Optimal,
|
||||
includeBaseDirectory: false);
|
||||
|
||||
return new BackupResult(targetZipPath, manifest, new FileInfo(targetZipPath).Length);
|
||||
}
|
||||
|
||||
// Verschluesselt: erst das ZIP neben den Staging-Ordner, dann als Ganzes
|
||||
// verschluesselt ans Ziel. Das unverschluesselte ZIP verlaesst den temporaeren
|
||||
// Ordner nie - es liegt dort mit Zugangsdaten, die nur noch durch die
|
||||
// Passphrase-Schicht geschuetzt sind.
|
||||
var klarZip = staging + ".zip";
|
||||
try
|
||||
{
|
||||
ZipFile.CreateFromDirectory(staging, klarZip, CompressionLevel.Optimal,
|
||||
includeBaseDirectory: false);
|
||||
|
||||
LicenseBackupCipher.Encrypt(
|
||||
klarZip, JsonSerializer.Serialize(manifest, JsonOptions),
|
||||
targetZipPath, options.LicenseKey!);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteFile(klarZip);
|
||||
}
|
||||
|
||||
return new BackupResult(targetZipPath, manifest, new FileInfo(targetZipPath).Length);
|
||||
}
|
||||
@@ -148,8 +175,24 @@ public sealed class BackupService
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>Liest das Manifest, ohne etwas auszupacken.</summary>
|
||||
public async Task<BackupManifest> InspectAsync(string zipPath, CancellationToken ct = default)
|
||||
/// <param name="licenseKey">Nur für verschlüsselte Sicherungen nötig.</param>
|
||||
/// <exception cref="BackupKeyMismatchException">
|
||||
/// Verschlüsselt, und der Schlüssel passt nicht.
|
||||
/// </exception>
|
||||
public async Task<BackupManifest> InspectAsync(
|
||||
string zipPath, CancellationToken ct = default, string? licenseKey = null)
|
||||
{
|
||||
if (LicenseBackupCipher.IsEncrypted(zipPath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(licenseKey))
|
||||
throw new BackupKeyMismatchException(
|
||||
"Diese Sicherung ist verschlüsselt; zum Lesen wird ein Lizenzschlüssel gebraucht.");
|
||||
|
||||
return JsonSerializer.Deserialize<BackupManifest>(
|
||||
LicenseBackupCipher.ReadManifest(zipPath, licenseKey))
|
||||
?? throw new BackupException("Das Manifest ist unlesbar.");
|
||||
}
|
||||
|
||||
using var archive = ZipFile.OpenRead(zipPath);
|
||||
|
||||
var entry = archive.GetEntry(ManifestName)
|
||||
@@ -170,6 +213,38 @@ public sealed class BackupService
|
||||
|
||||
public async Task<RestoreResult> RestoreAsync(
|
||||
string zipPath, string targetDir, RestoreOptions options, CancellationToken ct = default)
|
||||
{
|
||||
if (!LicenseBackupCipher.IsEncrypted(zipPath))
|
||||
return await RestoreZipAsync(zipPath, targetDir, options, ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.LicenseKey))
|
||||
throw new BackupKeyMismatchException(
|
||||
"Diese Sicherung ist verschlüsselt; zum Zurückspielen wird ein Lizenzschlüssel gebraucht.");
|
||||
|
||||
// Die Probe zuerst: Passt der Schluessel nicht, soll das vor dem Auspacken
|
||||
// feststehen und nicht mitten darin.
|
||||
if (!LicenseBackupCipher.MatchesKey(zipPath, options.LicenseKey))
|
||||
throw new BackupKeyMismatchException(
|
||||
"Diese Sicherung wurde mit einem anderen Lizenzschlüssel verschlüsselt.");
|
||||
|
||||
var klarZip = Path.Combine(Path.GetTempPath(), "clawd-restore-" + Guid.NewGuid().ToString("N") + ".zip");
|
||||
try
|
||||
{
|
||||
LicenseBackupCipher.DecryptTo(zipPath, klarZip, options.LicenseKey);
|
||||
|
||||
return await RestoreZipAsync(klarZip, targetDir, options with
|
||||
{
|
||||
Passphrase = LicenseBackupCipher.NormalizeKey(options.LicenseKey)
|
||||
}, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteFile(klarZip);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RestoreResult> RestoreZipAsync(
|
||||
string zipPath, string targetDir, RestoreOptions options, CancellationToken ct)
|
||||
{
|
||||
var manifest = await InspectAsync(zipPath, ct);
|
||||
|
||||
@@ -260,9 +335,18 @@ public sealed class BackupService
|
||||
return null;
|
||||
}
|
||||
|
||||
return options.Secrets == SecretMode.Passphrase
|
||||
? PassphraseProtector.Protect(plain, options.Passphrase!)
|
||||
: null;
|
||||
// Bei License tragen die Werte innen zusaetzlich die Passphrase-Schicht, mit dem
|
||||
// Lizenzschluessel als Passphrase. Doppelt, aber mit Absicht: So bleibt der
|
||||
// erprobte Weg zurueck (RewriteSecretsForRestore) derselbe, und ein
|
||||
// entschluesseltes Archiv, das jemand liegen laesst, enthaelt trotzdem keinen
|
||||
// Klartext.
|
||||
return options.Secrets switch
|
||||
{
|
||||
SecretMode.Passphrase => PassphraseProtector.Protect(plain, options.Passphrase!),
|
||||
SecretMode.License => PassphraseProtector.Protect(
|
||||
plain, LicenseBackupCipher.NormalizeKey(options.LicenseKey!)),
|
||||
_ => null
|
||||
};
|
||||
});
|
||||
|
||||
return (rewritten, count);
|
||||
@@ -386,6 +470,13 @@ public sealed class BackupService
|
||||
return full;
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); }
|
||||
catch (IOException) { /* Aufraeumen darf den eigentlichen Vorgang nicht umwerfen. */ }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); }
|
||||
|
||||
Reference in New Issue
Block a user