Przejdź do treści
HexaTransfer
Wróć do bloga
Zagadnienia techniczne

Szyfrowanie plików w JavaScript: tutorial krok po kroku

Szyfruj pliki w browserze za pomocą JavaScript. Praktyczny tutorial obejmujący szyfrowanie AES, derywację kluczy i bezpieczną obsługę plików.

JavaScript może szyfrować plik w całości w przeglądarce za pomocą Web Crypto API bez udziału serwera — co UODO uznaje za jeden z kluczowych środków technicznych wymaganych przez RODO przy przetwarzaniu danych wrażliwych. Standardowy pipeline: odczytaj plik jako ArrayBuffer, wyprowadź 256-bitowy klucz AES-GCM z hasła przez PBKDF2-SHA256 (600 000 iteracji), zaszyfruj z losowym 12-bajtowym IV i zapakuj salt, IV oraz szyfrogram do pobieralnego Blob. Każda nowoczesna przeglądarka obsługuje to natywnie przez window.crypto.subtle, a dla plików do 2–3 GB całość działa w poniżej dziesięciu sekund na nowoczesnym laptopie bez dotykania żadnej biblioteki zewnętrznej.

Wybory algorytmów, które mają znaczenie

Wybierz AES-GCM, nie AES-CBC. GCM zapewnia uwierzytelnione szyfrowanie w jednym przebiegu, wykrywając manipulację z 128-bitowym tagiem, podczas gdy CBC wymaga osobnego kroku HMAC, który większość tutoriali implementuje źle. Używaj klucza 256-bitowego — różnica wydajności względem 128-bitowego jest pomijalnie mała na sprzęcie z AES-NI. Wybierz PBKDF2-SHA256 do wyprowadzania klucza z hasła, chyba że możesz dostarczyć Argon2id przez WebAssembly — jest mocniejszy, ale dodaje 50 KB wagi pobierania.

Unikaj: trybu ECB (fundamentalnie zepsuty), ręcznie pisanego dopełnienia (dekada ataków padding oracle na CBC), MD5 lub SHA-1 (złamanych kolizyjnie) i wszystkiego z pakietu crypto-js bez rozumienia, że domyślnie używa CBC z PKCS7.

Odczytywanie pliku do pamięci

File API daje ci trzy sposoby na uzyskanie bajtów:

const buf = await file.arrayBuffer();          // cały plik
const stream = file.stream();                  // strumieniowanie
const text = await file.text();                // zdekodowany UTF-8

Dla plików powyżej 500 MB arrayBuffer() często zawodzi na mobilnym Safari. Zamiast tego strumieniuj:

async function* chunks(file, size = 4 * 1024 * 1024) {
  for (let off = 0; off < file.size; off += size) {
    yield new Uint8Array(await file.slice(off, off + size).arrayBuffer());
  }
}

Każdy slice odczytuje leniwie z dysku, więc szczytowe zużycie pamięci pozostaje ograniczone.

Wyprowadzanie klucza z hasła użytkownika

Nigdy nie przekazuj surowego hasła do encrypt. Najpierw wyprowadź klucz:

async function deriveKey(password, salt) {
  const enc = new TextEncoder();
  const material = await crypto.subtle.importKey(
    'raw', enc.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
    material,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

Generuj świeży 16-bajtowy salt per plik przez crypto.getRandomValues(new Uint8Array(16)). Przechowuj salt razem z szyfrogramem — ponowne użycie salt między plikami niszczy cel PBKDF2. Aktualne wskazówki OWASP zalecają 600 000 iteracji dla PBKDF2-SHA256, co przekłada się na około 500 ms wyprowadzania klucza na telefonie średniej klasy.

Szyfrowanie pliku

Z kluczem w ręku, szyfrowanie to jedno wywołanie subtle.encrypt per bufor:

async function encryptBuffer(key, plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv, tagLength: 128 },
    key,
    plaintext
  );
  return { iv, ciphertext };
}

GCM zawodzi katastrofalnie jeśli ponownie użyjesz pary (iv, key) — kolizja strumienia klucza ujawnia oba teksty jawne. 96-bitowy losowy IV daje ~2^48 bezpiecznych szyfrowania jednym kluczem, co jest w porządku dla szyfrowania plików. Jeśli szyfrujesz wiele fragmentów tym samym kluczem, wyprowadź IV z licznika plus losowy 32-bitowy prefiks.

Pakowanie wyjścia

Deszyfrator potrzebuje salt, IV i szyfrogramu. Zapakuj je do jednego bloba z małym nagłówkiem:

function pack(salt, iv, ciphertext) {
  const magic = new TextEncoder().encode('ENC1');
  return new Blob([magic, salt, iv, new Uint8Array(ciphertext)]);
}

Wersjonuj nagłówek (ENC1, ENC2…) żeby móc migrować algorytmy później bez łamania starych plików. Zaoferuj pobieranie przez:

const url = URL.createObjectURL(packed);
const a = document.createElement('a');
a.href = url; a.download = `${file.name}.enc`; a.click();
URL.revokeObjectURL(url);

Deszyfrowanie plików

Deszyfrowanie odwraca proces i rzuca OperationError jeśli hasło jest złe lub plik był manipulowany:

async function decryptFile(blob, password) {
  const buf = await blob.arrayBuffer();
  const view = new Uint8Array(buf);
  const magic = new TextDecoder().decode(view.slice(0, 4));
  if (magic !== 'ENC1') throw new Error('Unknown format');
  const salt = view.slice(4, 20);
  const iv = view.slice(20, 32);
  const ct = view.slice(32);
  const key = await deriveKey(password, salt);
  const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
  return new Blob([pt]);
}

Wyświetlaj jeden komunikat błędu — "nie można odszyfrować, złe hasło lub uszkodzony plik" — zamiast rozróżniania między błędem tagu a błędem strukturalnym. To usuwa wyciek informacji zbliżony do padding oracle dla atakujących.

Szyfrowanie dużych plików bez wyczerpania RAM

Dla wszystkiego powyżej 500 MB nie wywołuj arrayBuffer() na całym pliku. Szyfruj fragmenty niezależnie z unikalnymi IV wyprowadzonymi z licznika:

function ivForChunk(baseIV, index) {
  const iv = new Uint8Array(baseIV);
  const view = new DataView(iv.buffer);
  view.setUint32(8, index, false);
  return iv;
}

Przeprowadź plik przez TransformStream, zaszyfruj każdy fragment 4 MB i zapisz wyniki do WritableStream wskazującego na dysk przez File System Access API. Szczytowe zużycie pamięci utrzymuje się blisko 10 MB nawet dla pliku 20 GB.

Typowe błędy dostarczane na produkcję

Trzy wzorce pojawiają się w prawdziwych recenzjach kodu kryptografii JavaScript. Po pierwsze, przechowywanie surowego klucza w localStorage dla wygody — localStorage jest synchroniczny, zakresu origin i odczytywalny przez każdy XSS. Zamiast tego używaj niewyciągalnego CryptoKey w IndexedDB. Po drugie, używanie Math.random() do IV lub saltów — Math.random() jest przewidywalny; zawsze używaj crypto.getRandomValues. Po trzecie, zakładanie, że subtle.encrypt jest constant-time — jest w natywnych implementacjach przeglądarki, ale każdy wrapper JavaScript wokół niego prawie na pewno nie jest.

HexaTransfer stosuje dokładnie ten pipeline — PBKDF2 przy 600k iteracjach, AES-256-GCM, strumieniowane fragmenty — więc serwer przechowuje tylko nieprzejrzysty szyfrogram. Wypróbuj na https://hexatransfer.com — za darmo, bez konta, maks. 10 GB.

Testowanie implementacji

Napisz uprząż testową, która wykonuje round-trip losowego bloba 10 MB, mutuje bajt i sprawdza, że deszyfrowanie rzuca. Dodaj fuzzing przeciwko zniekształconym nagłówkom i obciętym szyfrogramom — typowy błąd to brakujące sprawdzanie granic w slice(), który crasha zamiast odrzucać. Benchmarkuj na iPhone SE, średnioklasowym Androidzie i Chromebooku — wszystko, co zajmuje ponad 2 sekundy wyprowadzania klucza, jest zbyt wolne dla użytkowników mobilnych. Przed wdrożeniem na produkcję zlekceważaj drugiej pary oczu — kod kryptografii wygląda prosto i psuje się w subtelny sposób, którego testy nie wychwytują.

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