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
+28 -3
View File
@@ -16,7 +16,18 @@ public enum SecretMode
/// Zugangsdaten werden mit einer Passphrase geschützt. Nur so überstehen sie
/// einen Rechner- oder Benutzerwechsel.
/// </summary>
Passphrase
Passphrase,
/// <summary>
/// Die ganze Sicherung wird mit dem Lizenzschlüssel verschlüsselt
/// (<see cref="LicenseBackupCipher"/>), die Zugangsdaten reisen mit.
///
/// <para>Der Weg, den der Instanzmanager nimmt. Er übersteht einen Maschinenwechsel,
/// ohne dass eine Passphrase gespeichert oder eingetippt werden müsste — der
/// Lizenzschlüssel ist ohnehin da. Deshalb darf auch die tägliche Sicherung jetzt
/// Zugangsdaten enthalten.</para>
/// </summary>
License
}
public sealed record BackupOptions
@@ -26,6 +37,9 @@ public sealed record BackupOptions
/// <summary>Erforderlich bei <see cref="SecretMode.Passphrase"/>.</summary>
public string? Passphrase { get; init; }
/// <summary>Erforderlich bei <see cref="SecretMode.License"/>.</summary>
public string? LicenseKey { get; init; }
/// <summary>Protokolle sind groß und selten nötig.</summary>
public bool IncludeLogs { get; init; }
@@ -37,6 +51,12 @@ public sealed record RestoreOptions
{
public string? Passphrase { get; init; }
/// <summary>
/// Für verschlüsselte Sicherungen: der Lizenzschlüssel der Installation, von der sie
/// stammt. Passt er nicht, kommt <see cref="BackupKeyMismatchException"/>.
/// </summary>
public string? LicenseKey { get; init; }
/// <summary>Nur prüfen und berichten, nichts schreiben.</summary>
public bool DryRun { get; init; }
@@ -74,8 +94,12 @@ public sealed record BackupManifest
[JsonPropertyName("files")]
public List<BackupEntry> Files { get; init; } = new();
/// <summary>
/// Enthält die Sicherung Zugangsdaten, die beim Zurückspielen auf den Schlüssel der
/// Zielmaschine umgeschlüsselt werden müssen?
/// </summary>
[JsonIgnore]
public bool HasSecrets => Secrets == nameof(SecretMode.Passphrase);
public bool HasSecrets => Secrets is nameof(SecretMode.Passphrase) or nameof(SecretMode.License);
}
public sealed record BackupResult(string ZipPath, BackupManifest Manifest, long SizeBytes);
@@ -88,5 +112,6 @@ public sealed record RestoreResult(
public bool HasConflicts => WouldOverwrite.Count > 0;
}
public sealed class BackupException(string message, Exception? inner = null)
/// <summary>Nicht versiegelt: <see cref="BackupKeyMismatchException"/> ist ein Sonderfall davon.</summary>
public class BackupException(string message, Exception? inner = null)
: Exception(message, inner);
+97 -6
View File
@@ -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); }
@@ -0,0 +1,305 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace ClawdDotNet.Core.Backup;
/// <summary>Die Sicherung wurde mit einem anderen Lizenzschlüssel verschlüsselt.</summary>
public sealed class BackupKeyMismatchException(string message) : BackupException(message);
/// <summary>
/// Verschlüsselt eine ganze Sicherung mit dem Lizenzschlüssel der Installation.
///
/// <para><b>Warum der Lizenzschlüssel.</b> Er ist der eine Wert, den jede Installation
/// ohnehin hat und den ihr Betreiber ohnehin kennt. Eine eigene Passphrase müsste
/// irgendwo gespeichert werden — und neben den Sicherungen abgelegt wäre sie wirkungslos;
/// genau deshalb sicherte die tägliche Sicherung bisher gar keine Zugangsdaten. Der
/// Lizenzschlüssel liegt im Manager, nicht bei den Archiven.</para>
///
/// <para><b>Was das aushält und was nicht.</b> Wer Lizenzschlüssel und Archiv hat, hat
/// alle Zugangsdaten darin. Der Schlüssel ist dem Hersteller bekannt, und er gilt für
/// alle Instanzen einer Installation. Das ist die bewusste Abwägung: Es geht darum, dass
/// ein Archiv auf einem FTP-Server oder einem USB-Stick niemandem nützt, der es nur
/// findet — nicht darum, jemanden mit Schlüssel und Absicht aufzuhalten.</para>
///
/// <para><b>Aufbau.</b> Kopf (Kennung, Fassung, Schlüsselprobe), dann das Manifest als
/// eigener verschlüsselter Abschnitt, dann das ZIP in Stücken zu 1 MiB, jedes mit
/// AES-256-GCM und eigener Nonce. Die Stückelung, weil eine Sicherung mit Protokollen
/// Hunderte Megabyte haben kann und AES-GCM in einem Zug alles im Speicher bräuchte. Das
/// Manifest vorn, damit die Liste der Sicherungen nicht jedes Archiv ganz entschlüsseln
/// muss. Die Schlüsselprobe, damit „falscher Lizenzschlüssel" von „beschädigt" zu
/// unterscheiden ist, bevor irgendetwas angehalten wird.</para>
///
/// <para><b>Kein ZIP mit Kennwort, kein RAR.</b> RAR zu schreiben braucht das
/// lizenzpflichtige Programm des Herstellers. Ein ZIP mit Kennwort kann .NET nicht
/// schreiben, und die verbreitete Variante (ZipCrypto) ist gebrochen. Weil der
/// eigentliche Schlüssel ohnehin abgeleitet wird, ließe sich ein solches ZIP auch mit
/// 7-Zip nicht öffnen — die Verträglichkeit brächte nichts.</para>
/// </summary>
public static class LicenseBackupCipher
{
/// <summary>Die Endung verschlüsselter Sicherungen.</summary>
public const string Extension = ".clawdbak";
private static readonly byte[] Magic = "CLAWDBAK"u8.ToArray();
private const byte FormatVersion = 1;
/// <summary>
/// Der feste Salt. Er steht im Code und ist damit in jeder Programmdatei — das ist
/// gewollt: Nur so ergibt derselbe Lizenzschlüssel auf jeder Maschine denselben
/// Schlüssel, und nur so lässt sich eine Sicherung anderswo öffnen.
/// </summary>
private static readonly byte[] FixedSalt = "ClawdDotNet.Backup.Lizenz.v1/7f3c9a51"u8.ToArray();
private const int Iterations = 210_000;
private const int KeySize = 32;
private const int NonceSize = 12;
private const int TagSize = 16;
private const int KeyCheckSize = 16;
private const int ChunkSize = 1024 * 1024;
private static readonly byte[] KeyCheckLabel = "clawd-backup-keycheck-v1"u8.ToArray();
private static readonly byte[] ManifestAad = "clawd-backup-manifest-v1"u8.ToArray();
/// <summary>
/// Abgeleitete Schlüssel je Lizenzschlüssel. PBKDF2 kostet mit Absicht Zeit — die
/// Liste von vierzehn Sicherungen soll sie nicht vierzehnmal zahlen.
/// </summary>
private static readonly ConcurrentDictionary<string, byte[]> Keys = new();
/// <summary>
/// Bringt einen Lizenzschlüssel in eine feste Form.
///
/// <para>Im Wiederherstellungsdialog tippt ein Mensch den Schlüssel einer anderen
/// Installation ab. Ein Leerzeichen am Ende oder ein kleiner Buchstabe dürfen dort nicht
/// den Unterschied zwischen „geht" und „verloren" machen.</para>
/// </summary>
public static string NormalizeKey(string licenseKey)
=> new string(licenseKey.Where(c => !char.IsWhiteSpace(c)).ToArray()).ToUpperInvariant();
/// <summary>Ist diese Datei eine verschlüsselte Sicherung?</summary>
public static bool IsEncrypted(string path)
{
try
{
using var datei = File.OpenRead(path);
Span<byte> kopf = stackalloc byte[8];
return datei.Read(kopf) == 8 && kopf.SequenceEqual(Magic);
}
catch (IOException) { return false; }
}
/// <summary>
/// Passt dieser Lizenzschlüssel zu der Sicherung? Liest nur den Kopf.
///
/// <para>Der Grund, warum es diese Probe gibt: Die Wiederherstellung hält die Instanz
/// an. Wer erst anhält und dann merkt, dass der Schlüssel nicht passt, hat eine laufende
/// Instanz für nichts gestoppt.</para>
/// </summary>
public static bool MatchesKey(string path, string licenseKey)
{
using var datei = File.OpenRead(path);
var probe = ReadHeader(datei);
return CryptographicOperations.FixedTimeEquals(probe, KeyCheck(Derive(licenseKey)));
}
/// <summary>Verschlüsselt ein fertiges ZIP samt Manifest in die Zieldatei.</summary>
public static void Encrypt(string zipPath, string manifestJson, string targetPath, string licenseKey)
{
if (string.IsNullOrWhiteSpace(licenseKey))
throw new BackupException(
"Ohne Lizenzschlüssel lässt sich keine verschlüsselte Sicherung anlegen.");
var key = Derive(licenseKey);
using var aus = File.Create(targetPath);
aus.Write(Magic);
aus.WriteByte(FormatVersion);
aus.Write(KeyCheck(key));
using var aes = new AesGcm(key, TagSize);
WriteSection(aus, aes, Encoding.UTF8.GetBytes(manifestJson), ManifestAad);
using var ein = File.OpenRead(zipPath);
var puffer = new byte[ChunkSize];
var naechster = new byte[ChunkSize];
long index = 0;
var gelesen = ReadFull(ein, puffer);
// Immer mindestens ein Stück, auch bei leerem Inhalt: Das letzte Stück traegt die
// Endmarke, und ohne sie liesse sich ein abgeschnittenes Archiv nicht erkennen.
while (true)
{
var folgend = gelesen == ChunkSize ? ReadFull(ein, naechster) : 0;
var letztes = folgend == 0;
WriteSection(aus, aes, puffer.AsSpan(0, gelesen), DataAad(index, letztes));
if (letztes) break;
(puffer, naechster) = (naechster, puffer);
gelesen = folgend;
index++;
}
}
/// <summary>Liest das Manifest, ohne das Archiv zu entschlüsseln.</summary>
/// <exception cref="BackupKeyMismatchException">Anderer Lizenzschlüssel.</exception>
public static string ReadManifest(string path, string licenseKey)
{
using var datei = File.OpenRead(path);
var key = Open(datei, licenseKey);
using var aes = new AesGcm(key, TagSize);
return Encoding.UTF8.GetString(ReadSection(datei, aes, ManifestAad));
}
/// <summary>Entschlüsselt das Archiv in eine ZIP-Datei.</summary>
/// <exception cref="BackupKeyMismatchException">Anderer Lizenzschlüssel.</exception>
public static void DecryptTo(string path, string zipTargetPath, string licenseKey)
{
using var datei = File.OpenRead(path);
var key = Open(datei, licenseKey);
using var aes = new AesGcm(key, TagSize);
ReadSection(datei, aes, ManifestAad); // ueberspringen, aber pruefen
using var aus = File.Create(zipTargetPath);
for (long index = 0; ; index++)
{
// Zuerst mit Endmarke versuchen, dann ohne. Welches passt, sagt der Tag - und
// nur so wird ein Archiv erkannt, dem hinten Stuecke fehlen.
var start = datei.Position;
if (TryReadSection(datei, aes, DataAad(index, letztes: true)) is { } schluss)
{
aus.Write(schluss);
if (datei.Position != datei.Length)
throw new BackupException("Nach dem letzten Abschnitt folgen noch Daten — die Sicherung ist beschädigt.");
return;
}
datei.Position = start;
aus.Write(ReadSection(datei, aes, DataAad(index, letztes: false)));
}
}
// ─── Innereien ───
private static byte[] Derive(string licenseKey)
=> Keys.GetOrAdd(NormalizeKey(licenseKey), k => Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(k), FixedSalt, Iterations, HashAlgorithmName.SHA256, KeySize));
private static byte[] KeyCheck(byte[] key)
=> HMACSHA256.HashData(key, KeyCheckLabel)[..KeyCheckSize];
private static byte[] ReadHeader(Stream datei)
{
Span<byte> magic = stackalloc byte[8];
if (datei.Read(magic) != 8 || !magic.SequenceEqual(Magic))
throw new BackupException("Das ist keine verschlüsselte ClawdDotNet-Sicherung.");
var version = datei.ReadByte();
if (version != FormatVersion)
throw new BackupException($"Unbekannte Fassung {version} der Sicherung — sie stammt von einer neueren Programmversion.");
var probe = new byte[KeyCheckSize];
if (ReadFull(datei, probe) != KeyCheckSize)
throw new BackupException("Die Sicherung ist abgeschnitten.");
return probe;
}
private static byte[] Open(Stream datei, string licenseKey)
{
var probe = ReadHeader(datei);
var key = Derive(licenseKey);
if (!CryptographicOperations.FixedTimeEquals(probe, KeyCheck(key)))
{
throw new BackupKeyMismatchException(
"Diese Sicherung wurde mit einem anderen Lizenzschlüssel verschlüsselt. "
+ "Zum Öffnen wird der Lizenzschlüssel der Installation gebraucht, von der sie stammt.");
}
return key;
}
private static byte[] DataAad(long index, bool letztes)
{
var aad = new byte[4 + 8 + 1];
"data"u8.CopyTo(aad);
BinaryPrimitives.WriteInt64LittleEndian(aad.AsSpan(4), index);
aad[12] = letztes ? (byte)1 : (byte)0;
return aad;
}
private static void WriteSection(Stream aus, AesGcm aes, ReadOnlySpan<byte> klar, byte[] aad)
{
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var geheim = new byte[klar.Length];
var tag = new byte[TagSize];
aes.Encrypt(nonce, klar, geheim, tag, aad);
Span<byte> laenge = stackalloc byte[4];
BinaryPrimitives.WriteInt32LittleEndian(laenge, geheim.Length);
aus.Write(nonce);
aus.Write(laenge);
aus.Write(tag);
aus.Write(geheim);
}
private static byte[] ReadSection(Stream ein, AesGcm aes, byte[] aad)
=> TryReadSection(ein, aes, aad)
?? throw new BackupException("Die Sicherung ist beschädigt oder wurde verändert.");
private static byte[]? TryReadSection(Stream ein, AesGcm aes, byte[] aad)
{
var nonce = new byte[NonceSize];
var laenge = new byte[4];
var tag = new byte[TagSize];
if (ReadFull(ein, nonce) != NonceSize || ReadFull(ein, laenge) != 4 || ReadFull(ein, tag) != TagSize)
throw new BackupException("Die Sicherung ist abgeschnitten.");
var n = BinaryPrimitives.ReadInt32LittleEndian(laenge);
if (n < 0 || n > ChunkSize * 64)
throw new BackupException("Die Sicherung ist beschädigt.");
var geheim = new byte[n];
if (ReadFull(ein, geheim) != n)
throw new BackupException("Die Sicherung ist abgeschnitten.");
var klar = new byte[n];
try
{
aes.Decrypt(nonce, geheim, tag, klar, aad);
return klar;
}
catch (AuthenticationTagMismatchException)
{
return null;
}
}
private static int ReadFull(Stream ein, byte[] puffer)
{
var gesamt = 0;
while (gesamt < puffer.Length)
{
var n = ein.Read(puffer, gesamt, puffer.Length - gesamt);
if (n == 0) break;
gesamt += n;
}
return gesamt;
}
}