Перейти к содержанию
HexaTransfer
Вернуться к блогу
Шифрование и безопасность

Туториал клиентского шифрования: создайте с нуля

Пошаговый туториал по реализации клиентского шифрования. Шифруйте файлы в браузере до их отправки с устройства.

Клиентское шифрование файлов в браузере занимает около 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 ГБ бесплатно со сквозным шифрованием. Регистрация не требуется. Ваши файлы шифруются в браузере перед загрузкой — никто другой не может их прочитать.

Отправить файл