Zum Inhalt springen
HexaTransfer
Zurück zum Blog
Verschlusselung & Sicherheit

Progressive Verschlüsselung großer Dateien: streamen und verschlüsseln

Verschlüsseln Sie große Dateien progressiv mit Streaming-APIs. Verarbeiten Sie Multi-GB-Dateien ohne Speicherprobleme.

Progressive (Streaming-) Verschlüsselung verarbeitet eine Datei Chunk für Chunk, ohne jemals die vollständige Nutzlast in den Speicher zu laden. Für einen 10-GB-Upload in einem Browser ist das der Unterschied zwischen einer App, die funktioniert, und einer, die abstürzt. Das Muster: einen Chunk via File.stream() lesen, ihn mit AES-256-GCM mit einer einzigartigen Nonce verschlüsseln, den Chiffretext direkt über fetch mit einem ReadableStream-Body in einen Upload-Stream pipen, den Puffer freigeben und weitermachen. Der Speicher bleibt unabhängig von der Dateigröße auf 4–16 MB begrenzt. libsodiums crypto_secretstream_xchacha20poly1305 fügt ordnungsgemäße Streaming-AEAD-Semantik einschließlich Abschneideerkennung hinzu. Hier ist die konkrete Implementierung mit Zahlen, die auf echter Hardware standhalten.

Warum gepufferte Verschlüsselung versagt

Ein FileReader.readAsArrayBuffer(file) auf einer 10-GB-Datei alloziert 10 GB im Browser-Speicher. Auf Desktop-Chrome mit 32 GB RAM mag das funktionieren. Auf Mobile Safari mit einem 400-MB-Per-Tab-Speicherlimit stürzt es ab, bevor es fertig ist. In Firefox trifft ein ArrayBuffer über 2 GB interne V8-Limits und wirft RangeError.

Selbst auf Hardware, die die Allokation handhaben kann, blockiert das Halten von 10 GB den Garbage Collector und löst pathologisches Paging aus. Die richtige Antwort ist, den vollständigen Puffer nie zu allozieren.

Das Streaming-Muster

async function streamEncrypt(file, key, uploadURL) {
  const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
  const reader = file.stream().getReader();
  let chunkIndex = 0;
  let buffer = new Uint8Array(0);

  const uploadStream = new ReadableStream({
    async pull(controller) {
      while (buffer.length < CHUNK_SIZE) {
        const { done, value } = await reader.read();
        if (done) {
          if (buffer.length > 0) {
            await enqueueEncrypted(controller, buffer, chunkIndex++, key);
          }
          controller.close();
          return;
        }
        const newBuf = new Uint8Array(buffer.length + value.length);
        newBuf.set(buffer, 0);
        newBuf.set(value, buffer.length);
        buffer = newBuf;
      }
      const chunk = buffer.subarray(0, CHUNK_SIZE);
      buffer = buffer.subarray(CHUNK_SIZE);
      await enqueueEncrypted(controller, chunk, chunkIndex++, key);
    }
  });

  await fetch(uploadURL, {
    method: "POST",
    body: uploadStream,
    duplex: "half",
    headers: { "Content-Type": "application/octet-stream" },
  });
}

async function enqueueEncrypted(controller, chunk, index, key) {
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setBigUint64(4, BigInt(index));
  const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, chunk);
  controller.enqueue(new Uint8Array(ct));
}

Zwei zentrale APIs: File.stream() gibt einen ReadableStream des Dateiinhalts zurück; fetch mit einem ReadableStream-Body streamt den Upload ohne Pufferung des gesamten Bodies. duplex: "half" ist in Chrome 105+ für Streaming-Request-Bodies erforderlich.

Speichernutzung: Zu jedem Zeitpunkt ein Quell-Chunk, ein gepufferter Rest, ein verschlüsselter Chunk. Peak ~12–16 MB bei 4-MB-Chunk-Größe.

Nonce-Verwaltung in Streams

Jeder Chunk benötigt eine einzigartige Nonce. Drei Ansätze:

Zählerbasiert: Betten Sie den Chunk-Index in die 96-Bit-Nonce ein. Setzen Sie die oberen 32 Bits auf ein zufälliges Präfix (um Kollisionen über Dateien mit demselben Schlüssel zu vermeiden), die unteren 64 Bits auf den Chunk-Index.

const noncePrefix = crypto.getRandomValues(new Uint32Array(1));
function makeNonce(chunkIndex) {
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setUint32(0, noncePrefix[0]);
  new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex));
  return iv;
}

Zufällig pro Chunk: crypto.getRandomValues(new Uint8Array(12)). Sicher für Per-Datei-Schlüssel; Geburtstags-Kollisionen über Chunks treffen bei ~2^48. Speichern Sie die Nonce neben dem Chiffretext jedes Chunks.

Via HKDF abgeleitet: Verwenden Sie HKDF, um Per-Chunk-Schlüssel abzuleiten, dann eine feste Nonce. Für die meisten Fälle überdimensioniert.

Für einen frischen Per-Datei-Schlüssel ist der zählerbasierte Ansatz am einfachsten und vermeidet die Notwendigkeit, eine separate Nonce pro Chunk zu speichern.

Abschneideangriffe und wie man sie erkennt

Eine kritische Lücke in naiver Chunk-AES-GCM: Ein Angreifer kann abschließende Chunks verwerfen, und jeder überlebende Chunk entschlüsselt problemlos. Erkennung erfordert das Binden von Chunks aneinander.

Option 1: Gesamtanzahl der Chunks in die AAD jedes Chunks aufnehmen. Der Empfänger verifiziert, dass die Anzahl mit dem Empfangenen übereinstimmt.

const aad = new TextEncoder().encode(JSON.stringify({
  totalChunks,
  fileSize: file.size,
}));
const ct = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv, additionalData: aad },
  key,
  chunk
);

Option 2: libsodiums crypto_secretstream_xchacha20poly1305 verwenden. Es verkettet Chunks kryptografisch und gibt einen TAG_FINAL-Marker aus, den der Empfänger verifiziert:

const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
// Für jeden Chunk mit TAG_MESSAGE pushen
// Für den letzten Chunk mit TAG_FINAL pushen
const lastCt = sodium.crypto_secretstream_xchacha20poly1305_push(
  state, lastChunk, null,
  sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
);

Beim Entschlüsseln verifizieren die pull-Aufrufe des Empfängers die Kette und erkennen fehlende Endchunks. Das ist die sauberste Option, wenn Sie bereit sind, libsodium.js mitzuliefern.

Streaming-Entschlüsselung auf der Empfängerseite

Symmetrisches Muster auf der Empfängerseite:

async function streamDecrypt(downloadURL, key, onChunk) {
  const response = await fetch(downloadURL);
  const reader = response.body.getReader();
  let buffer = new Uint8Array(0);
  let chunkIndex = 0;
  const ENCRYPTED_CHUNK_SIZE = 4 * 1024 * 1024 + 16; // plus GCM-Tag

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const newBuf = new Uint8Array(buffer.length + value.length);
    newBuf.set(buffer);
    newBuf.set(value, buffer.length);
    buffer = newBuf;
    while (buffer.length >= ENCRYPTED_CHUNK_SIZE) {
      const ct = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
      buffer = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
      const iv = makeNonce(chunkIndex++);
      const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
      onChunk(new Uint8Array(pt));
    }
  }
  // Letzten partiellen Chunk verarbeiten
  if (buffer.length > 0) {
    const iv = makeNonce(chunkIndex);
    const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, buffer);
    onChunk(new Uint8Array(pt));
  }
}

Auf der Empfängerseite können onChunk-Callbacks entschlüsselte Bytes über die File System Access API direkt auf die Disk schreiben oder in einen Blob für browser-nativen Download konkatenieren.

Auf die Disk schreiben via File System Access API

Für sehr große Downloads würde das Laden des vollständigen entschlüsselten Ergebnisses in einen Blob den Zweck des Streamings zunichtemachen. Die File System Access API (Chrome 86+, partiell Safari via OPFS) lässt den Empfänger eine lokale Datei wählen und Chunks direkt schreiben:

const handle = await window.showSaveFilePicker({
  suggestedName: "entschluesselte-datei",
});
const writable = await handle.createWritable();

await streamDecrypt(url, key, async (chunk) => {
  await writable.write(chunk);
});
await writable.close();

Der Speicher bleibt begrenzt, weil Chunks sofort auf die Disk gehen. Die Oberfläche zeigt realistischen Fortschritt. Nutzer können den Download abbrechen.

Firefox unterstützt showSaveFilePicker auf Desktop noch nicht. Als Fallback dient das Erstellen eines Blobs im Speicher (OK für Dateien unter ein paar Hundert MB) oder das Origin Private File System für Multi-GB-Firefox-Workflows.

Upload-Streaming via fetch

Chrome 105+ und Firefox 127+ unterstützen Streaming-Request-Bodies mit duplex: "half". Vorher mussten Uploads entweder vollständige Puffer oder Multipart mit manuell behandeltem Chunked-Transfer-Encoding sein.

Für S3-kompatible Multipart-Uploads wird jeder Teil als separate Anfrage hochgeladen. Teilen Sie den verschlüsselten Stream in Teile von 5–25 MB auf (S3-Mindest-Teilgröße ist 5 MB, Maximum 5 GB) und schließen Sie mit dem finalen CompleteMultipartUpload-Aufruf ab. Das funktioniert in allen Browsern und gibt Wiederaufnahmefähigkeit kostenlos dazu.

Fortschrittsanzeige über Streams

Verarbeitete Bytes verfolgen:

let processed = 0;
const onChunk = (chunkSize) => {
  processed += chunkSize;
  updateProgressBar(processed / file.size);
};

Drosseln Sie Fortschritts-Updates auf 10–20 Hz mit requestAnimationFrame, um verschwendete Neuzeichnungen zu vermeiden. Bei 10-GB-Dateien bei 100-MB/s-Verarbeitungsgeschwindigkeit sind das immer noch 100 Rohereignisse pro Sekunde — weit mehr, als die Oberfläche benötigt.

Benchmarks für eine 10-GB-Datei

Auf einem 2024 MacBook Pro (M3 Max) mit schneller SSD: Rohlesung von Disk via File.stream() bei 2,5 GB/s, AES-256-GCM via Web Crypto bei 1,7 GB/s, die kombinierte Pipeline bei 1,1 GB/s (durch die serielle Kette begrenzt), Upload über Gigabit-Ethernet bei 115 MB/s (netzwerkgebunden), Speicher-Peak 14 MB unabhängig von der Dateigröße. Mobile-Werte sind grob 30–50 % von Desktop. Eine 10-GB-Datei lädt in ~90 Sekunden bei Gigabit hoch, ~15 Minuten bei einer typischen Heimverbindung. Verschlüsselung ist nicht der Engpass — das Netzwerk ist es.

Fehlerwiederherstellung

Netzwerkunterbrechungen während eines 10-GB-Uploads sind häufig. Strategien:

  • Wiederaufnehmbare Uploads via Multipart: Jeder Teil ist unabhängig; laden Sie nur den fehlgeschlagenen Teil erneut hoch.
  • tus-Protokoll: Offener Standard für wiederaufnehmbare Uploads, der von Unternehmen wie Vimeo unterstützt wird; stream-nativ.
  • Quelldatei-Handle offen halten: Wenn File.slice wiederholbar ist, starten Sie ab dem letzten erfolgreichen Chunk neu.

HexaTransfers 10-GB-Grenze ist in einem einzigen Browser-Tab erreichbar, weil diese Streaming-Pipeline den Speicher begrenzt und Unterbrechungen mit Multipart-Retry graceful handhabt. Dasselbe Muster skaliert auf größere Grenzen, wenn Ihr Backend das unterstützt.

Die Kurzfassung

Allozieren Sie nicht die gesamte Datei. Lesen Sie in Chunks, verschlüsseln Sie in Chunks, laden Sie in Chunks hoch, geben Sie jeden Chunk frei, sobald Sie fertig sind. Binden Sie Chunks kryptografisch mit AAD oder Streaming-AEAD, um Abschneidungen abzuwehren. Versehen Sie alles mit einer Fortschrittsanzeige. Testen Sie auf Mobile, nicht nur auf Desktop.

Auf hexatransfer.com testen — kostenlos, ohne Konto, bis 10 GB.

Große Dateien sicher mit Ende-zu-Ende-Verschlüsselung senden

Übertragen Sie Dateien bis zu 10 GB kostenlos mit Ende-zu-Ende-Verschlüsselung. Kein Konto erforderlich. Ihre Dateien werden in Ihrem Browser verschlüsselt, bevor sie hochgeladen werden — niemand sonst kann sie lesen.

Datei senden