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

Tutorial szyfrowania po stronie klienta: zbuduj od podstaw

Tutorial krok po kroku do implementacji szyfrowania po stronie klienta. Szyfruj pliki w przeglądarce zanim opuszczą urządzenie.

Szyfrowanie pliku po stronie klienta w przeglądarce wymaga ok. 80 linii JavaScript z użyciem Web Crypto API. Wzorzec: wygeneruj klucz AES-256-GCM w przeglądarce, zaszyfruj plik losowym 96-bitowym nonce, prześlij szyfrogram przez HTTPS/TLS 1.3, a wynikowy URL udostępnij z kluczem osadzonym we fragmencie identyfikatora (#key=...), którego przeglądarki nigdy nie przesyłają na serwery. Odbiorca odszyfrowuje w przeglądarce używając tego samego fragmentu. Ten tutorial przeprowadza przez działającą implementację, w tym fragmentowanie dla dużych plików, klucze wyprowadzane z hasła przez PBKDF2 przy 600 000 iteracjach, oraz pułapki psujące pierwsze próby.

Architektura w jednym diagramie

[Przeglądarka nadawcy]                 [Serwer]                    [Przeglądarka odbiorcy]
  Odczytaj plik → klucz AES (losowy)   Akceptuje POST               GET szyfrogram
  Zaszyfruj AES-256-GCM                Przechowuje blob szyfrogramu  Wyodrębnij klucz z fragmentu URL
  POST szyfrogram                      Brak klucza, brak jawnego     Odszyfruj w przeglądarce
  Zbuduj URL z #key=...                tekstu                        Zapisz plik na dysk
                                       Zwraca URL pobierania

Serwer to głupi magazyn blobów. Widzi tylko szyfrogram i nie może odszyfrować. Klucz deszyfrowania żyje we fragmencie URL, który przeglądarki traktują specjalnie: nigdy nie jest przesyłany w linii żądania HTTP. To fundament każdej usługi zero-knowledge transferu pliku, w tym HexaTransfer.

Krok 1: Wygeneruj klucz symetryczny

async function generateKey() {
  return await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true, // ekstrahowalny, by eksportować do URL
    ["encrypt", "decrypt"]
  );
}

Flaga extractable: true jest wymagana, bo musimy serializować klucz do fragmentu URL. Jeśli budujesz przepływ gdzie klucz żyje tylko w pamięci (np. narzędzie wklej-i-wyślij), ustaw false.

Krok 2: Odczytaj plik jako ArrayBuffer

async function readFile(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsArrayBuffer(file);
  });
}

To ładuje cały plik do pamięci. Odpowiednie dla plików poniżej 500 MB. Dla większych plików przejdź do sekcji strumieniowej.

Krok 3: Zaszyfruj bufor

async function encryptFile(key, plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    plaintext
  );
  // Dołącz IV przed szyfrogramem, by odbiorca mógł go wyodrębnić
  const combined = new Uint8Array(iv.length + ciphertext.byteLength);
  combined.set(iv, 0);
  combined.set(new Uint8Array(ciphertext), iv.length);
  return combined.buffer;
}

Nonce (IV) to 96 bitów (12 bajtów) zgodnie z NIST SP 800-38D. Nie jest sekretem, ale musi być unikalny na klucz. Losowe nonce są bezpieczne, bo generujemy świeży klucz na plik. Poprzedzanie IV szyfrogramem to powszechna konwencja; odbiorca oddziela go przed deszyfrowaniem.

Krok 4: Prześlij szyfrogram

async function uploadCiphertext(ciphertext) {
  const response = await fetch("/api/upload", {
    method: "POST",
    body: ciphertext,
    headers: { "Content-Type": "application/octet-stream" },
  });
  const { fileId } = await response.json();
  return fileId;
}

Serwer otrzymuje blob binarny, przypisuje mu ID, przechowuje i zwraca to ID. Żadne nagłówki nie ujawniają nazwy pliku, żadne parametry zapytania nie niosą klucza. Jeśli jutro dysk serwera zostanie skradziony, atakujący zobaczy tylko szumowy tekst.

Krok 5: Zbuduj URL udostępnienia z kluczem we fragmencie

async function buildShareURL(fileId, key) {
  const rawKey = await crypto.subtle.exportKey("raw", key);
  const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return `${location.origin}/f/${fileId}#${keyBase64}`;
}

Kodowanie base64url (z - i _ zamiast + i /) unika problemów z escapowaniem URL. Dopełnienie = jest usuwane dla estetyki.

Fragment (#...) jest tu kluczowy. Gdy odbiorca ładuje URL, przeglądarka zachowuje fragment po stronie klienta. Żądanie HTTP GET dla /f/{fileId} nie zawiera #keyBase64 w linii żądania, więc serwer nigdy nie poznaje klucza. Zweryfikuj to sam, otwierając narzędzia deweloperskie przeglądarki dla dowolnego URL z fragmentem i obserwując zakładkę Sieć.

Krok 6: Deszyfrowanie po stronie odbiorcy

async function downloadAndDecrypt() {
  const fileId = location.pathname.split("/").pop();
  const keyBase64 = location.hash.slice(1);
  const rawKey = Uint8Array.from(
    atob(keyBase64.replace(/-/g, "+").replace(/_/g, "/")),
    c => c.charCodeAt(0)
  );
  const key = await crypto.subtle.importKey(
    "raw", rawKey, "AES-GCM", false, ["decrypt"]
  );
  const response = await fetch(`/api/download/${fileId}`);
  const combined = new Uint8Array(await response.arrayBuffer());
  const iv = combined.slice(0, 12);
  const ciphertext = combined.slice(12);
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv }, key, ciphertext
  );
  const blob = new Blob([plaintext]);
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "downloaded-file";
  a.click();
}

Tag uwierzytelniający GCM jest sprawdzany podczas decrypt(). Jeśli szyfrogram został zmodyfikowany, wywołanie zgłasza OperationError — czysty tryb awarii.

Klucze wyprowadzane z hasła przez PBKDF2

Jeśli użytkownicy podają hasło zamiast losowego klucza, wyprowadź klucz AES przez PBKDF2:

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

600 000 iteracji PBKDF2-SHA-256 to baseline OWASP 2023. Sól to 16 losowych bajtów przechowywanych razem z szyfrogramem (nie jest sekretem, musi być unikalna). Dla nowego kodu rozważ Argon2id przez bibliotekę taką jak argon2-browser — znacznie lepiej odpiera ataki GPU.

Strumieniowanie dużych plików

Pliki powyżej 500 MB powinny być fragmentowane. Odczytuj przez File.stream(), szyfruj każdy fragment, wysyłaj sekwencyjnie:

async function encryptStream(file, key) {
  const reader = file.stream().getReader();
  const chunks = [];
  let chunkIndex = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const iv = new Uint8Array(12);
    // Zakoduj indeks fragmentu w nonce, by zagwarantować unikalność
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, value
    );
    chunks.push({ iv, ct });
  }
  return chunks;
}

Wyprowadzenie nonce z indeksu fragmentu gwarantuje unikalność bez śledzenia stanu. Składanie u odbiorcy odszyfrowuje fragmenty w kolejności i łączy.

Dla prawdziwego strumieniowego AEAD, crypto_secretstream_xchacha20poly1305 libsodium przez libsodium.js jest czystsze i wykrywa ataki obcięcia. Web Crypto nie ma odpowiednika tego prymitywu w 2026 roku.

Testowanie i pułapki

Typowe błędy do unikania:

  • Używanie Math.random() do kluczy lub nonce: zawsze crypto.getRandomValues().
  • Ponowne użycie nonce z tym samym kluczem: niszczy bezpieczeństwo GCM. Klucze losowe per plik sprawiają, że to bezpieczne; przepływy per fragment potrzebują unikalnych nonce per fragment.
  • Brak sprawdzenia HTTPS: crypto.subtle jest niezdefiniowane w niezabezpieczonych źródłach. Testuj na localhost lub z certyfikatem self-signed podczas dewelopmentu.
  • Przechowywanie kluczy w localStorage: każdy XSS w Twoim źródle może to odczytać. Zamiast tego używaj wzorca fragmentu URL lub kluczy nieekstrahowalnych.
  • Zapomnienie o dołączeniu IV z szyfrogramem: deszyfrowanie nie powiedzie się bez przydatnego komunikatu błędu. Zawsze poprzedzaj lub serializuj razem.
  • Błędna obsługa fragmentu: nie wysyłaj przypadkowo URL (z fragmentem) do usługi trzeciej strony. Udostępniaj tylko przez kanały end-to-end, jeśli fragment jest wrażliwy.

Obowiązki po stronie serwera

Zadanie serwera w architekturze szyfrowania po stronie klienta jest małe: akceptuj POST, przechowuj blob, zwróć ID, obsługuj GET dla bloba, usuń po wygaśnięciu. Żadnej kryptografii. Co serwer powinien robić poza przechowywaniem:

  • Wymuszać limity rozmiaru pliku (zapobiegać nadużyciom)
  • Ograniczać przesyłanie i pobieranie przez rate limiting
  • Ustawiać krótką retencję (7 dni to rozsądna wartość domyślna, jak w HexaTransfer)
  • Logować tylko to, co konieczne (znacznik czasu przesłania, bez IP jeśli serwis ma priorytet prywatności)
  • Serwować przez TLS 1.3 z HSTS
  • Nagłówki CORS ograniczające źródła, jeśli API jest wywoływane tylko z Twoich domen

Składanie wszystkiego razem

Minimalna działająca aplikacja mieści się w jednym pliku HTML plus 50-liniowym backendzie Express. Łączne zależności: żadnych po stronie klienta (Web Crypto jest natywny), Express plus multer po stronie serwera. Szyfrowanie jest tak silne jak prymityw AES-256-GCM — bo dosłownie z niego korzystasz. Nie ma tu żadnego tajemnego algorytmu do zepsucia, tylko prymitywy do poprawnego użycia.

Najtrudniejsze są przypadki brzegowe: duże pliki, przepływy hasło-do-klucza, UX odbiorcy gdy deszyfrowanie nie powiedzie się, łagodna obsługa wygasłych linków. Podstawowa kryptografia jest prosta.

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