Przejdź do treści
HexaTransfer
Wróć do bloga
Szyfrowanie i bezpieczenstwo

Optymalizacja wydajności szyfrowania: szybka kryptografia w przeglądarce

Optymalizuj wydajność szyfrowania dla dużych transferów. Szyfrowanie strumieniowe, Web Workers i przetwarzanie fragmentami.

Szyfrowanie pliku 5 GB w przeglądarce bez blokowania UI wymaga zestawu konkretnych technik: Web Crypto API dla sprzętowo akcelerowanego AES-256-GCM (1–2 GB/s z AES-NI), przetwarzania fragmentami w blokach 1–4 MB dla ograniczenia pamięci, Web Workers do utrzymania responsywności głównego wątku, odczytu strumieniowego przez File.stream() zamiast FileReader.readAsArrayBuffer(), oraz starannego zarządzania nonce, by fragmenty mogły być szyfrowane równolegle. Biblioteki kryptograficzne czysto-JS działają 10–20x wolniej niż Web Crypto i powinny być zarezerwowane dla prymitywów, których przeglądarka nie udostępnia natywnie. Oto jak osiągnąć przepustowość kilkuset megabajtów na sekundę na rzeczywistym sprzęcie użytkownika.

Najpierw zmierz punkt bazowy

Przed optymalizowaniem — mierz. Na MacBook Air M2 z 2024 roku w Chrome 120, szyfrowanie bufora 1 GB AES-256-GCM przez Web Crypto w ciasnej pętli działa z ~1,7 GB/s. Ta sama operacja na telefonie ze średniej półki (Pixel 7) działa z ~600 MB/s. Laptop Intel z 2015 roku z oprogramowaniem AES osiąga ~250 MB/s.

Co to mówi: Web Crypto AES-GCM zazwyczaj nie jest wąskim gardłem w szyfrowanych przepływach transferu pliku. Wąskim gardłem jest zwykle odczyt pliku, przesyłanie JavaScript między ArrayBuffers lub wysyłanie przez sieć. Najpierw optymalizuj tamte elementy.

Testy do uruchomienia w aplikacji:

const blob = new Uint8Array(1024 * 1024 * 100); // 100 MB
const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 }, true, ["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const start = performance.now();
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, blob);
console.log(`${(100 / (performance.now() - start) * 1000).toFixed(0)} MB/s`);

Uruchom w Chrome, Firefox, Safari. Chrome na Apple Silicon będzie najszybszy; Firefox nieco wolniejszy; Safari na Intel lekko w tyle. Urządzenia mobilne działają z ok. 30–50% prędkości desktopa.

Używaj Web Crypto, nie bibliotek JavaScript

Dla AES-GCM, AES-CBC, PBKDF2, HMAC, RSA, ECDH, ECDSA i SHA-256/384/512, natywne Web Crypto API przeglądarki używa akceleracji sprzętowej tam gdzie dostępna (AES-NI na x86, rozszerzenia krypto ARMv8 na urządzeniach mobilnych). Biblioteki JavaScript jak @noble/ciphers lub czysty JS crypto-js nie mają dostępu do tych instrukcji i działają czysto w interpreterze.

Typowy stosunek prędkości dla AES-256-GCM na danych wejściowych 1 MB:

  • Web Crypto (akceleracja sprzętowa): 1–2 GB/s
  • libsodium.js WASM: 400–800 MB/s
  • @noble/ciphers czysty JS: 100–200 MB/s
  • crypto-js czysty JS: 30–80 MB/s

Dla pliku 5 GB różnica między Web Crypto a czystym JS to ~3 sekundy versus ~50 sekund. Widoczne dla użytkownika. Zawsze preferuj Web Crypto dla tego co obsługuje. Używaj bibliotek WASM (libsodium.js, argon2-browser) tylko dla algorytmów nieobsługiwanych przez Web Crypto (ChaCha20-Poly1305, Argon2id, X25519 na starszych przeglądarkach).

Przetwarzanie fragmentami dla dużych plików

Pliki powyżej ~500 MB nie mieszczą się wygodnie w jednym ArrayBuffer na większości urządzeń. Podziel je na fragmenty 1–4 MB i szyfruj każdy.

const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB

async function encryptLargeFile(file, key) {
  const chunks = [];
  let chunkIndex = 0;
  for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
    const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
    const iv = new Uint8Array(12);
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, chunk
    );
    chunks.push({ iv, ct: new Uint8Array(ct) });
  }
  return chunks;
}

Dlaczego fragmenty 4 MB? Mniejsze fragmenty (np. 64 KB) powodują overhead per wywołanie Web Crypto API, który dominuje dla małych danych wejściowych. Większe fragmenty (np. 64 MB) nie mieszczą się dobrze w cache L2/L3 i mają gorszą presję pamięci. 1–4 MB to zazwyczaj optymalne dla desktopa i urządzeń mobilnych.

Web Workers dla zachowania responsywności UI

Szyfrowanie w głównym wątku blokuje renderowanie i input. Dla operacji trwających ponad ~500 ms przenieś do Web Worker.

// worker.js
self.onmessage = async (e) => {
  const { chunk, key, iv } = e.data;
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, chunk
  );
  self.postMessage(ciphertext, [ciphertext]);
};

// główny wątek
const worker = new Worker('/worker.js');
worker.postMessage({ chunk, key, iv }, [chunk]);
worker.onmessage = (e) => { /* obsłuż szyfrogram */ };

Przenoś ArrayBuffers z drugim argumentem do postMessage — przenosi to własność (zero-copy) zamiast klonowania. Bez przenoszenia, klonowanie fragmentu 4 MB dodaje ~20 ms overhead na fragment.

Web Crypto API jest dostępne w Workers, więc faktyczne szyfrowanie działa tam z identyczną wydajnością jak w głównym wątku.

Równoległe szyfrowanie fragmentów

Szyfrowanie AES-GCM per fragment jest niezależne, o ile nonce nie kolidują. Wyprowadzaj nonce deterministycznie z indeksu fragmentu i możesz szyfrować fragmenty równolegle w wielu Workers. Malejące zwroty pojawiają się po ~4 workerach na typowym sprzęcie, bo Web Crypto jest tak szybkie, że wąskie gardło przesuwa się na odczyt pliku i przesyłanie wiadomości między wątkami. Zmierz przed angażowaniem się w złożoność; czasem sekwencyjne szyfrowanie w jednym workerze jest tak szybkie jak równoległe wieloworkowe z powodu overhead.

Strumieniowanie z Readable Streams

Dla naprawdę dużych plików (20+ GB) unikaj ładowania nawet fragmentów do pamięci wszystkich naraz. Używaj File.stream():

const reader = file.stream().getReader();
const writer = uploadStream.getWriter();
let chunkIndex = 0;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, value
  );
  await writer.write(new Uint8Array(ct));
}
await writer.close();

Dzięki temu zużycie pamięci jest ograniczone do jednego fragmentu naraz. Przeglądarka czyta z dysku, szyfruje, zapisuje do strumienia wysyłania, czyta następny fragment. Użycie pamięci pozostaje poniżej 10 MB niezależnie od rozmiaru pliku.

Równoległość wysyłania

Szyfrowanie działa równolegle z wysyłaniem, nie szeregowo. Podczas gdy fragment N jest wysyłany, fragment N+1 jest szyfrowany. Używaj potoku:

const encryptQueue = [];
const uploadQueue = [];
const MAX_IN_FLIGHT = 4;

// Szyfrujący produkuje, wysyłający konsumuje
async function pipeline() {
  // Uruchom szyfrowanie pierwszych fragmentów
  for (let i = 0; i < MAX_IN_FLIGHT; i++) startEncrypt(i);
  // Gdy każdy się skończy, uruchom wysyłanie i wstaw następne szyfrowanie do kolejki
  // (ograniczona kolejka utrzymuje zużycie pamięci przewidywalne)
}

TCP slow-start i overhead uzgodnienia TLS sprawiają, że początkowe wysyłanie jest wolne. Utrzymywanie 4–8 równoczesnych wysyłań do jednego źródła zapełnia rurę bez przekraczania limitów przeglądarki (6 połączeń per źródło w Chrome/Firefox).

WebAssembly dla brakujących prymitywów

Dla wyprowadzania kluczy Argon2id lub ChaCha20-Poly1305, Web Crypto nie ma natywnego wsparcia. Biblioteki WASM wypełniają lukę:

  • libsodium.js dostarcza Argon2id, XChaCha20-Poly1305, crypto_secretstream
  • argon2-browser dostarcza tylko Argon2 ale mniejszy pakiet
  • @noble/hashes dostarcza czysty JS Argon2 (wolniejszy) z małym pakietem

Wersje WASM osiągają 60–80% natywnej prędkości dla większości obciążeń kryptograficznych. Dla transferu pliku, 1–2 sekundowe wyprowadzenie Argon2id przy wysyłaniu i pobieraniu jest akceptowalne; 5-sekundowe czysto-JS — nie.

Ładuj WASM dynamicznie, by nie blokować pierwszego renderowania strony:

const sodium = await import('libsodium-wrappers');
await sodium.ready;

Raportowanie postępu

Duże szyfrowania potrzebują informacji zwrotnej o postępie lub użytkownicy zakładają, że aplikacja się zawiesiła. Licz zaszyfrowane bajty i wysyłaj zdarzenia postępu:

let processed = 0;
for await (const chunk of chunks) {
  await encryptChunk(chunk);
  processed += chunk.size;
  onProgress({ done: processed, total: file.size, pct: processed / file.size });
}

Ogranicz aktualizacje UI do ~10 Hz przez requestAnimationFrame lub prostą kontrolę znacznika czasu; częstsze aktualizacje marnują cykle na repainty, których ludzie nie widzą.

Pułap pamięci i presja GC

Każdy ArrayBuffer żyje dopóki jest referencja. Przechowywanie 20 zaszyfrowanych fragmentów po 4 MB każdy oznacza ~80 MB przypiętej pamięci. W przeglądarkach mobilnych z ciasnym limitem pamięci (iOS Safari ogranicza do ~200–400 MB per karta), ma to znaczenie.

Zwalniaj referencje niezwłocznie:

for (let i = 0; i < chunks.length; i++) {
  const chunk = chunks[i];
  chunks[i] = null; // pozwól GC odzyskać
  const ct = await encrypt(chunk);
  await upload(ct);
}

Unikaj przechowywania pełnej tablicy zaszyfrowanych fragmentów w pamięci; przesyłaj je do wysyłającego i zwalniaj referencje na bieżąco.

Realne cele wydajności

Dla transferu zaszyfrowanego pliku 1 GB w karcie przeglądarki: czas szyfrowania 1–3 sekundy (sprzętowo akcelerowany), czas wysyłania 30 sekund przy połączeniu 300 Mbps, łącznie ok. 35 sekund czasu rzeczywistego (głównie ograniczony siecią), pamięć poniżej 50 MB szczytowo przy właściwym strumieniowaniu, UI responsywne przez cały czas (główny wątek nigdy nie zablokowany powyżej 50 ms). Chybienie tych celów jest zauważalne dla użytkowników. Limit 10 GB HexaTransfer jest osiągalny w przeglądarce, bo Web Crypto plus strumieniowanie fragmentowane utrzymuje całą ścieżkę efektywną.

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