Перейти к содержанию
HexaTransfer
Вернуться к блогу
Технические погружения

Шифрование файлов на JavaScript: пошаговый учебник

Шифруйте файлы в браузере с помощью JavaScript. Практический учебник по AES-шифрованию, выводу ключей и безопасной работе с файлами.

JavaScript позволяет шифровать файлы полностью в браузере через Web Crypto API без какого-либо участия сервера. Стандартный конвейер: читаем файл как ArrayBuffer, выводим 256-битный ключ AES-GCM из пароля через PBKDF2-SHA256 (600 000 итераций), шифруем со случайным 12-байтовым IV, упаковываем соль, IV и шифртекст в скачиваемый Blob. Все основные браузеры поддерживают это нативно через window.crypto.subtle, и для файлов до 2–3 ГБ весь процесс занимает менее десяти секунд на современном ноутбуке без сторонних библиотек.

Выбор алгоритмов, который имеет значение

Используйте AES-GCM, а не AES-CBC. GCM даёт аутентифицированное шифрование за один проход с 128-битным тегом аутентичности, тогда как CBC требует отдельного шага HMAC, который большинство руководств реализуют неправильно. Используйте 256-битный ключ — разница в производительности против 128-битного ничтожна на железе с AES-NI. Выбирайте PBKDF2-SHA256 для вывода ключа из пароля, если не можете поставить Argon2id через WebAssembly — он сильнее, но добавляет 50 КБ к загрузке.

Избегайте: режима ECB (фундаментально сломан), самодельного дополнения (десятилетие атак CBC padding oracle), MD5 и SHA-1 (атаки на коллизии), и пакета crypto-js без понимания, что он по умолчанию использует CBC с PKCS7.

Чтение файла в память

API File предлагает три способа получить байты:

const buf = await file.arrayBuffer();          // весь файл
const stream = file.stream();                  // потоковое чтение
const text = await file.text();                // декодирование UTF-8

Для файлов выше 500 МБ arrayBuffer() часто падает в мобильном Safari. Используйте потоки:

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());
  }
}

Каждый срез читается лениво с диска, поэтому пиковое потребление памяти остаётся ограниченным.

Вывод ключа из пользовательского пароля

Никогда не передавайте сырой пароль в encrypt. Сначала выводите ключ:

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']
  );
}

Генерируйте свежую 16-байтовую соль для каждого файла через crypto.getRandomValues(new Uint8Array(16)). Храните соль вместе с шифртекстом — повторное использование соли между файлами нивелирует смысл PBKDF2. Декабрьская 2026 года памятка OWASP по хранению паролей рекомендует 600 000 итераций для PBKDF2-SHA256, что соответствует примерно 500 мс вывода ключа на среднем телефоне.

Шифрование файла

Имея ключ, шифрование — один вызов subtle.encrypt на буфер:

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 катастрофически ломается при повторном использовании пары (iv, key) — коллизия потока ключей раскрывает оба открытых текста. 96-битный случайный IV даёт ~2^48 безопасных шифрований одним ключом, что достаточно для шифрования файлов. Если вы шифруете много чанков одним ключом, выводите IV из счётчика плюс случайный 32-битный префикс.

Упаковка результата

Дешифратору нужны соль, IV и шифртекст. Упакуйте их в единый blob с небольшим заголовком:

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

Версионируйте заголовок (ENC1, ENC2…), чтобы впоследствии можно было мигрировать алгоритмы без поломки старых файлов. Предложите скачивание через:

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

Дешифрование файлов

Дешифрование обращает процесс и бросает OperationError, если пароль неверный или файл был подделан:

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]);
}

Показывайте одно сообщение об ошибке — «не удалось расшифровать: неверный пароль или повреждённый файл» — без различения между сбоем тега и структурным сбоем. Это устраняет утечку информации атакующим, аналогичную padding-oracle.

Шифрование больших файлов без исчерпания RAM

Для всего выше 500 МБ не вызывайте arrayBuffer() на весь файл. Шифруйте чанки независимо с уникальными IV, выведенными из счётчика:

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

Прогоняйте файл через TransformStream, шифруйте каждый чанк по 4 МБ и записывайте результаты в WritableStream, указывающий на диск через File System Access API. Пиковое потребление памяти остаётся около 10 МБ даже для файла в 20 ГБ. Чанкованный формат должен записывать размер и количество чанков в заголовке, чтобы дешифратор мог корректно пересобрать данные.

Типичные ошибки, доходящие до продакшена

Три паттерна встречаются в реальных код-ревью JavaScript-крипто:

Во-первых, хранение сырого ключа в localStorage для удобства. localStorage синхронный, ограничен origin и читается любым XSS. Используйте вместо этого неизвлекаемый CryptoKey в IndexedDB.

Во-вторых, использование Math.random() для IV или соли. Math.random() предсказуем; всегда используйте crypto.getRandomValues.

В-третьих, предположение, что subtle.encrypt работает за константное время. В нативных реализациях браузера — да, но любая JavaScript-обёртка вокруг него почти наверняка нет. Держите свой код вне горячего пути.

HexaTransfer применяет именно этот конвейер — PBKDF2 при 600к итерациях, AES-256-GCM, потоковые чанки — так что сервер хранит только непрозрачный шифртекст. Попробуйте на hexatransfer.com — бесплатно, без регистрации, до 10 ГБ.

Тестирование реализации

Напишите тестовый стенд, выполняющий round-trip случайного 10 МБ blob, мутирующий байт и проверяющий, что дешифрование бросает исключение. Добавьте фаззинг против искажённых заголовков и усечённых шифртекстов — типичная ошибка — отсутствие проверок границ в slice(), приводящее к крашу вместо отклонения. Делайте замеры на iPhone SE, среднем Android и Chromebook; всё, что занимает более 2 секунд на вывод ключа, слишком медленно для мобильных пользователей. Перед выводом в продакшен проверьте реализацию с помощью второй пары глаз — криптокод выглядит просто и ломается тонкими способами, которые тесты пропускают.

Безопасная отправка больших файлов со сквозным шифрованием

Передавайте файлы до 10 ГБ бесплатно со сквозным шифрованием. Регистрация не требуется. Ваши файлы шифруются в браузере перед загрузкой — никто другой не может их прочитать.

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