Туториал клиентского шифрования: создайте с нуля
Пошаговый туториал по реализации клиентского шифрования. Шифруйте файлы в браузере до их отправки с устройства.
Клиентское шифрование файлов в браузере занимает около 80 строк JavaScript с использованием Web Crypto API. Паттерн: генерация AES-256-GCM ключа в браузере, шифрование файла случайным 96-битным nonce, загрузка шифротекста по HTTPS/TLS 1.3 и публикация результирующего URL с ключом во фрагменте идентификатора (#key=...), который браузеры никогда не передают серверам. Получатель дешифрует в браузере с использованием того же фрагмента. Этот туториал разбирает рабочую реализацию, включая чанкование для больших файлов, ключи на основе пароля через PBKDF2 с 600 000 итерациями и типичные ошибки, которые ломают первые попытки.
Архитектура в одной схеме
[Браузер отправителя] [Сервер] [Браузер получателя]
Читает файл → AES ключ (random) Принимает POST GET шифротекст
Шифрует AES-256-GCM Хранит зашифр. blob Парсит ключ из URL #fragment
POST шифротекст Нет ключа, нет текста Дешифрует в браузере
Строит URL с #key=... Возвращает URL загрузки Сохраняет файл на диск
Сервер — простое хранилище blob. Он видит только шифротекст и не может расшифровать. Ключ дешифрования живёт во фрагменте URL, который браузеры обрабатывают особым образом: он никогда не отправляется в строке HTTP-запроса. Это основа каждого zero-knowledge сервиса передачи файлов, включая HexaTransfer.
Шаг 1: Генерация симметричного ключа
async function generateKey() {
return await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true, // extractable для экспорта в URL
["encrypt", "decrypt"]
);
}
Флаг extractable: true необходим, поскольку ключ нужно сериализовать во фрагмент URL. Если вы создаёте поток, где ключ живёт только в памяти (например, инструмент «вставь и отправь»), установите false.
Шаг 2: Чтение файла как 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);
});
}
Это загружает весь файл в память. Подходит для файлов до 500 МБ. Для больших файлов переходите к разделу о потоковой обработке.
Шаг 3: Шифрование буфера
async function encryptFile(key, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plaintext
);
// Добавляем IV перед шифротекстом, чтобы получатель мог его извлечь
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.length);
return combined.buffer;
}
Nonce (IV) составляет 96 бит (12 байт) согласно NIST SP 800-38D. Он не является секретом, но должен быть уникальным для каждого ключа. Случайные nonce безопасны, поскольку мы генерируем свежий ключ для каждого файла. Добавление IV перед шифротекстом — распространённое соглашение; получатель отделяет его перед дешифрованием.
Шаг 4: Загрузка шифротекста
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;
}
Сервер получает бинарный blob, присваивает ему идентификатор, сохраняет и возвращает этот идентификатор. Никакие заголовки не раскрывают имя файла, никакие параметры запроса не несут ключ. Если завтра жёсткий диск сервера будет украден, атакующий увидит бессмысленный набор байтов.
Шаг 5: Формирование URL с ключом во фрагменте
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}`;
}
Кодировка base64url (с - и _ вместо + и /) исключает проблемы экранирования URL. Дополнение = убирается для аккуратности.
Фрагмент (#...) работает особым образом. Когда получатель загружает URL, браузер оставляет фрагмент на стороне клиента. HTTP GET для /f/{fileId} не включает #keyBase64 в строку запроса, поэтому сервер никогда не узнаёт ключ. Убедитесь в этом самостоятельно: откройте инструменты разработчика браузера на любом URL с фрагментом и посмотрите вкладку Network.
Шаг 6: Дешифрование на стороне получателя
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();
}
Тег аутентификации GCM проверяется при вызове decrypt(). Если шифротекст был подделан, вызов выбрасывает OperationError — понятный режим сбоя.
Ключи на основе пароля через PBKDF2
Если пользователи предоставляют пароль вместо случайного ключа, выведите AES-ключ через 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 итераций PBKDF2-SHA-256 — базовый уровень OWASP 2023. Соль должна содержать 16 случайных байт и храниться рядом с шифротекстом (не является секретом, но должна быть уникальной). Для нового кода рассмотрите Argon2id через библиотеку argon2-browser — он значительно лучше противостоит GPU-атакам.
Потоковая обработка больших файлов
Файлы свыше 500 МБ следует разбивать на чанки. Читайте через File.stream(), шифруйте каждый чанк, загружайте последовательно:
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);
// Кодируем индекс чанка в nonce для гарантии уникальности
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;
}
Вывод nonce из индекса чанка гарантирует уникальность без отслеживания состояния. При восстановлении на стороне получателя дешифруйте чанки по порядку и конкатенируйте.
Для истинного потокового AEAD crypto_secretstream_xchacha20poly1305 из libsodium через libsodium.js — более чистое решение, обнаруживающее атаки усечения. Эквивалентного примитива в Web Crypto в 2026 году нет.
Тестирование и типичные ошибки
Ошибки, которых следует избегать:
- Использование
Math.random()для ключей или nonce: всегда используйтеcrypto.getRandomValues(). - Повторное использование nonce с тем же ключом: нарушает безопасность GCM. Случайные ключи на файл делают это безопасным; потоковые паттерны требуют уникальных nonce для каждого чанка.
- Отсутствие проверки HTTPS:
crypto.subtleне определён в незащищённых источниках. Тестируйте на localhost или с самоподписанным сертификатом в разработке. - Хранение ключей в
localStorage: любой XSS в вашем источнике может их прочитать. Вместо этого используйте паттерн URL-фрагмента или неизвлекаемые ключи. - Забыть включить IV с шифротекстом: дешифрование провалится без информативной ошибки. Всегда добавляйте или сериализуйте рядом.
- Неаккуратная работа с фрагментом: случайно не публикуйте URL (с фрагментом) на сторонние сервисы. Делитесь только через сквозные каналы, если фрагмент чувствителен.
Обязанности серверной стороны
Роль сервера в архитектуре с клиентским шифрованием невелика: принять POST, сохранить blob, вернуть ID, обслуживать GET для blob, удалить по истечении срока хранения. Никакой криптографии. Что сервер должен делать помимо хранения:
- Ограничивать размер файлов (предотвращение злоупотреблений)
- Применять rate limiting для загрузок и выгрузок
- Устанавливать короткий срок хранения (7 дней — разумный вариант по умолчанию, как в HexaTransfer)
- Логировать только необходимое (временная метка загрузки, без IP при приоритете конфиденциальности)
- Работать по TLS 1.3 с HSTS
- Заголовки CORS, ограничивающие источники, если API вызывается только из ваших доменов
Сборка воедино
Минимальное рабочее приложение помещается в один HTML-файл плюс 50-строчный Express-бэкенд. Зависимости: на клиенте нет (Web Crypto встроен), на сервере — Express плюс multer. Шифрование настолько же надёжно, насколько надёжен примитив AES-256-GCM — потому что именно его вы и используете. Нет никакого тайного алгоритма, который можно сделать неправильно, есть только примитивы, которые нужно использовать корректно.
Наиболее сложные части — граничные случаи: большие файлы, паттерны пароль-в-ключ, UX для получателя при неудачном дешифровании, корректная обработка истёкших ссылок. Базовая криптография проста.
Попробуйте на hexatransfer.com — бесплатно, без регистрации, до 10 ГБ.
Безопасная отправка больших файлов со сквозным шифрованием
Передавайте файлы до 10 ГБ бесплатно со сквозным шифрованием. Регистрация не требуется. Ваши файлы шифруются в браузере перед загрузкой — никто другой не может их прочитать.
Отправить файл