Progresywne szyfrowanie dużych plików: strumieniowe szyfrowanie
Szyfruj progresywnie duże pliki za pomocą API strumieniowych. Przetwarzaj pliki wielogigabajtowe bez braku pamięci.
Progresywne (strumieniowe) szyfrowanie przetwarza plik fragment po fragmencie, nigdy nie wczytując pełnego ładunku do pamięci. Dla pliku 10 GB przesyłanego z poziomu przeglądarki to różnica między aplikacją działającą sprawnie a taką, która się zawiesza. Schemat jest prosty: odczytaj fragment przez File.stream(), zaszyfruj go AES-256-GCM z unikalnym nonce, prześlij strumieniowo zaszyfrowany tekst do serwera przez fetch z ciałem ReadableStream, zwolnij bufor i przejdź do następnego. Pamięć pozostaje ograniczona do 4–16 MB niezależnie od rozmiaru pliku. Biblioteka libsodium przez crypto_secretstream_xchacha20poly1305 dodaje właściwą semantykę strumieniowego AEAD, w tym wykrywanie obcięcia danych.
Dlaczego buforowane szyfrowanie zawodzi
Wywołanie FileReader.readAsArrayBuffer(file) na pliku 10 GB alokuje 10 GB w pamięci przeglądarki. Na desktopowym Chrome z 32 GB RAM może to zadziałać. W mobilnym Safari z limitem 400 MB na zakładkę — przeglądarka zawiesza się przed ukończeniem. W Firefox obiekt ArrayBuffer przekraczający 2 GB trafia na wewnętrzne ograniczenia silnika i zgłasza RangeError.
Nawet na sprzęcie, który udźwignie alokację, przechowywanie 10 GB blokuje garbage collector i wywołuje patologiczne przerzucanie stron pamięci. Prawidłowe podejście to nigdy nie alokować pełnego bufora.
Wzorzec strumieniowania
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));
}
Dwa kluczowe API: File.stream() dostarcza ReadableStream z zawartością pliku; fetch z ciałem ReadableStream przesyła dane strumieniowo bez buforowania całego ładunku. duplex: "half" jest wymagany w Chrome 105+ dla strumieniowych ciał żądań.
Zużycie pamięci: w danej chwili jeden fragment źródłowy, jedna buforowana reszta i jeden zaszyfrowany fragment. Szczyt wynosi ok. 12–16 MB przy rozmiarze fragmentu 4 MB.
Zarządzanie nonce w strumieniach
Każdy fragment wymaga unikalnego nonce. Trzy podejścia:
Licznikowe: wbuduj indeks fragmentu w 96-bitowy nonce. Górne 32 bity ustaw jako losowy prefiks (by uniknąć kolizji między plikami korzystającymi z tego samego klucza), dolne 64 bity — na indeks fragmentu.
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;
}
Losowy dla każdego fragmentu: crypto.getRandomValues(new Uint8Array(12)). Bezpieczne dla kluczy jednorazowego użytku; kolizje urodzinowe między fragmentami osiągają ~2^48. Nonce należy przechowywać obok kryptotekstu każdego fragmentu.
Wyprowadzony przez HKDF: użyj HKDF do wyprowadzenia kluczy per-fragment, a następnie zastosuj stały nonce. Nadmiernie skomplikowane dla większości zastosowań.
Dla świeżego klucza per-plik podejście licznikowe jest najprostsze i eliminuje konieczność przechowywania osobnego nonce per-fragment.
Ataki przez obcięcie i jak je wykryć
Krytyczna luka naiwnego fragmentowanego AES-GCM: atakujący może upuścić końcowe fragmenty, a każdy ocalały deszyfruje się poprawnie. Wykrycie wymaga kryptograficznego powiązania fragmentów.
Opcja 1: W danych dodatkowych (AAD) każdego fragmentu umieść łączną liczbę fragmentów. Odbiorca weryfikuje, czy liczba odpowiada otrzymanej.
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
);
Opcja 2: Użyj crypto_secretstream_xchacha20poly1305 z libsodium. Biblioteka łączy fragmenty kryptograficznie i emituje znacznik TAG_FINAL weryfikowany przez odbiorcę:
const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
// Dla każdego fragmentu: push z TAG_MESSAGE
// Dla ostatniego fragmentu: push z TAG_FINAL
const lastCt = sodium.crypto_secretstream_xchacha20poly1305_push(
state, lastChunk, null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
);
Przy deszyfrowaniu wywołania pull odbiorcy weryfikują łańcuch i wykrywają brakujące końcowe fragmenty. To najczystsze rozwiązanie przy gotowości do dołączenia libsodium.js.
Strumieniowe deszyfrowanie po stronie odbiorcy
Symetryczny wzorzec po stronie odbiorcy:
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 tag GCM
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));
}
}
if (buffer.length > 0) {
const iv = makeNonce(chunkIndex);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, buffer);
onChunk(new Uint8Array(pt));
}
}
Wywołania zwrotne onChunk mogą kierować odszyfrowane bajty do File System Access API w celu bezpośredniego zapisu na dysk lub łączyć je w Blob do natywnego pobierania w przeglądarce.
Zapis na dysk przez File System Access API
Dla bardzo dużych pobrań ładowanie pełnego odszyfrowanego wyniku do obiektu Blob niweczy sens strumieniowania. File System Access API (Chrome 86+, częściowe wsparcie Safari przez OPFS) pozwala odbiorcy wybrać lokalny plik i zapisywać fragmenty bezpośrednio:
const handle = await window.showSaveFilePicker({
suggestedName: "odszyfrowany-plik",
});
const writable = await handle.createWritable();
await streamDecrypt(url, key, async (chunk) => {
await writable.write(chunk);
});
await writable.close();
Pamięć pozostaje ograniczona, ponieważ fragmenty trafiają na dysk natychmiast. Interfejs wyświetla realistyczny postęp. Użytkownicy mogą anulować pobieranie w trakcie.
Firefox nie obsługuje jeszcze showSaveFilePicker na desktopie. Fallback to budowanie Blob w pamięci (dopuszczalne dla plików poniżej kilkuset MB) lub Origin Private File System dla dużych plików w środowisku Firefox.
Przesyłanie strumieniowe przez fetch
Chrome 105+ i Firefox 127+ obsługują strumieniowe ciała żądań z duplex: "half". Wcześniej przesyłanie wymagało albo kompletnych buforów, albo wieloczęściowego transferu z ręcznie obsługiwanym chunked transfer encoding.
Dla wieloczęściowych przesyłań kompatybilnych z S3 każda część jest wysyłana jako osobne żądanie. Podziel zaszyfrowany strumień na części 5–25 MB (minimalna wielkość części S3 to 5 MB, maksymalna 5 GB) i zakończ ostatecznym wywołaniem CompleteMultipartUpload. Działa we wszystkich przeglądarkach i zapewnia wznawianie bezpośrednio.
Raportowanie postępu nad strumieniami
Śledź przetworzone bajty:
let processed = 0;
const onChunk = (chunkSize) => {
processed += chunkSize;
updateProgressBar(processed / file.size);
};
Ogranicz częstotliwość aktualizacji do 10–20 Hz przez requestAnimationFrame, by uniknąć zbędnych przerenderowań. Przy pliku 10 GB i prędkości przetwarzania 100 MB/s surowe zdarzenia generują 100 wywołań na sekundę — wielokrotnie więcej niż potrzebuje interfejs.
Wyniki na pliku 10 GB
Na MacBook Pro 2024 (M3 Max) z szybkim SSD: surowy odczyt z dysku przez File.stream() wynosi 2,5 GB/s, AES-256-GCM przez Web Crypto — 1,7 GB/s, połączony potok — 1,1 GB/s (ograniczony szeregowym łańcuchem), przesyłanie przez Gigabit Ethernet — 115 MB/s (ograniczone siecią), szczyt pamięci — 14 MB niezależnie od rozmiaru pliku. Wyniki mobilne to ok. 30–50% wartości desktopowych. Plik 10 GB przesyła się w ~90 sekund na Gigabicie i ~15 minut przy typowym łączu domowym. Wąskim gardłem jest sieć, a nie szyfrowanie.
Odtwarzanie po błędach sieciowych
Przerwy w sieci podczas przesyłania 10 GB są powszechne. Strategie:
- Wznawialne przesyłanie przez multipart: każda część jest niezależna; wystarczy ponownie przesłać tylko nieudaną część.
- Protokół tus: otwarty standard wznawianych przesyłań obsługiwany m.in. przez Vimeo; natywny dla strumieni.
- Zachowanie uchwytu pliku źródłowego: jeśli
File.slicejest powtarzalny, wznów od ostatniego udanego fragmentu.
Limit 10 GB HexaTransfer jest osiągalny w jednej zakładce przeglądarki właśnie dlatego, że ten potok strumieniowy utrzymuje ograniczone zużycie pamięci i obsługuje przerwy przez ponowne próby multipart.
Krótkie podsumowanie
Nie alokuj całego pliku. Odczytuj fragmentami, szyfruj fragmentami, przesyłaj fragmentami, zwalniaj każdy fragment po przetworzeniu. Powiąż fragmenty kryptograficznie przez AAD lub strumieniowy AEAD, by uniemożliwić ataki przez obcięcie. Dodaj pasek postępu. Testuj na urządzeniach mobilnych, nie tylko na desktopach.
Wypróbuj na hexatransfer.com — bezpłatnie, bez konta, do 10 GB.
Wysyłaj duże pliki bezpiecznie z szyfrowaniem end-to-end
Przesyłaj pliki do 10 GB za darmo z szyfrowaniem end-to-end. Bez rejestracji. Twoje pliki są szyfrowane w przeglądarce przed przesłaniem — nikt inny nie może ich odczytać.
Wyślij plik