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: zawszecrypto.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.subtlejest 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