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

Оптимизация производительности шифрования: быстрая крипто в браузере

Оптимизируйте производительность шифрования для больших передач. Потоковое шифрование, Web Workers и обработка чанками.

Зашифровать файл 5 ГБ в браузере, не убив UI, позволяет конкретный набор техник: Web Crypto API с аппаратно-ускоренным AES-256-GCM (1-2 ГБ/с при наличии AES-NI), чанкованная обработка блоками по 1-4 МБ для ограничения памяти, Web Workers для сохранения отзывчивости основного потока, потоковое чтение через File.stream() вместо FileReader.readAsArrayBuffer() и аккуратное управление nonce для параллельного шифрования чанков. Библиотеки крипто на чистом JavaScript работают в 10-20 раз медленнее Web Crypto и должны использоваться только для примитивов, недоступных нативно в браузере. Вот как достичь производительности в сотни мегабайт в секунду на реальном пользовательском железе.

Сначала измерьте базу

Перед оптимизацией — измерения. На MacBook Air M2 2024 года в Chrome 120, шифрование 1 ГБ буфера с AES-256-GCM через Web Crypto в цикле даёт ~1.7 ГБ/с. Та же операция на среднем Android-телефоне (Pixel 7) — ~600 МБ/с. Ноутбук Intel 2015 года без аппаратного AES — ~250 МБ/с.

Вывод: Web Crypto AES-GCM не является узким местом для большинства потоков зашифрованной передачи файлов. Узкое место — обычно чтение файла, маршалинг JavaScript между ArrayBuffer или загрузка по сети. Оптимизируйте эти места в первую очередь.

Бенчмарки для запуска в приложении:

const blob = new Uint8Array(1024 * 1024 * 100); // 100 МБ
const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 }, true, ["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const start = performance.now();
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, blob);
console.log(`${(100 / (performance.now() - start) * 1000).toFixed(0)} МБ/с`);

Запускайте в Chrome, Firefox, Safari. Chrome на Apple Silicon будет самым быстрым; Firefox чуть медленнее; Safari на Intel немного отстаёт. Мобильные устройства работают примерно на 30-50% скорости десктопа.

Используйте Web Crypto, а не JavaScript-библиотеки

Для AES-GCM, AES-CBC, PBKDF2, HMAC, RSA, ECDH, ECDSA и SHA-256/384/512 нативный Web Crypto API браузера использует аппаратное ускорение при наличии (AES-NI на x86, криптографические расширения ARMv8 на мобильных). Библиотеки JavaScript — @noble/ciphers или чистый crypto-js — не имеют доступа к этим инструкциям и работают только в интерпретаторе.

Типичное соотношение скоростей для AES-256-GCM на входных данных 1 МБ:

  • Web Crypto (аппаратное ускорение): 1-2 ГБ/с
  • libsodium.js WASM: 400-800 МБ/с
  • @noble/ciphers чистый JS: 100-200 МБ/с
  • crypto-js чистый JS: 30-80 МБ/с

Для файла 5 ГБ разница между Web Crypto и чистым JS — примерно 3 секунды против ~50 секунд. Заметно для пользователя. Всегда предпочитайте Web Crypto для поддерживаемых алгоритмов. WASM-библиотеки (libsodium.js, argon2-browser) используйте только для алгоритмов, которых нет в Web Crypto (ChaCha20-Poly1305, Argon2id, X25519 в старых браузерах).

Чанкованная обработка для больших файлов

Файлы свыше ~500 МБ не помещаются комфортно в один ArrayBuffer на большинстве устройств. Разбивайте на части по 1-4 МБ и шифруйте каждую.

const CHUNK_SIZE = 4 * 1024 * 1024; // 4 МБ

async function encryptLargeFile(file, key) {
  const chunks = [];
  let chunkIndex = 0;
  for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
    const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
    const iv = new Uint8Array(12);
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, chunk
    );
    chunks.push({ iv, ct: new Uint8Array(ct) });
  }
  return chunks;
}

Почему 4 МБ? Меньшие чанки (например, 64 КБ) несут накладные расходы на вызов Web Crypto API, которые доминируют для малых входных данных. Большие чанки (например, 64 МБ) плохо умещаются в кэш L2/L3 и создают большее давление на память. 1-4 МБ — оптимальный диапазон для десктопа и мобильных устройств.

Web Workers для сохранения отзывчивости UI

Шифрование в основном потоке блокирует рендеринг и ввод. Для работы свыше ~500 мс переносите в Web Worker.

// worker.js
self.onmessage = async (e) => {
  const { chunk, key, iv } = e.data;
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, chunk
  );
  self.postMessage(ciphertext, [ciphertext]);
};

// основной поток
const worker = new Worker('/worker.js');
worker.postMessage({ chunk, key, iv }, [chunk]);
worker.onmessage = (e) => { /* обработка ct */ };

Передавайте ArrayBuffer вторым аргументом postMessage — это передаёт владение (zero-copy), а не клонирует. Без transfer клонирование чанка в 4 МБ добавляет ~20 мс накладных расходов на чанк.

Web Crypto API доступен в Workers, поэтому реальное шифрование выполняется там с идентичной производительностью по сравнению с основным потоком.

Параллельное шифрование чанков

Шифрование AES-GCM отдельных чанков независимо при условии отсутствия коллизий nonce. Выводите nonce детерминированно из индекса чанка, и можно шифровать чанки параллельно на нескольких Workers. Уменьшение отдачи наступает примерно после 4 workers на типичном железе, поскольку Web Crypto настолько быстр, что узкое место смещается на чтение файлов и передачу сообщений между потоками. Проводите бенчмарки перед тем, как брать на себя сложность; иногда однопоточное последовательное шифрование так же быстро, как многопоточное параллельное, из-за накладных расходов.

Потоковая обработка с Readable Streams

Для действительно огромных файлов (20+ ГБ) избегайте загрузки даже чанков в память за раз. Используйте File.stream():

const reader = file.stream().getReader();
const writer = uploadStream.getWriter();
let chunkIndex = 0;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, value
  );
  await writer.write(new Uint8Array(ct));
}
await writer.close();

Это ограничивает использование памяти одним чанком в каждый момент. Браузер читает с диска, шифрует, записывает в поток загрузки, читает следующий чанк. Использование памяти остаётся ниже 10 МБ независимо от размера файла.

Параллелизм загрузки

Шифрование выполняется параллельно с загрузкой, а не последовательно. Пока загружается чанк N, шифруется чанк N+1. Используйте конвейер:

const encryptQueue = [];
const uploadQueue = [];
const MAX_IN_FLIGHT = 4;

// Шифровщик производит, загрузчик потребляет
async function pipeline() {
  // Запуск шифрования первых чанков
  for (let i = 0; i < MAX_IN_FLIGHT; i++) startEncrypt(i);
  // По завершении каждого запускаем загрузку и ставим в очередь следующее шифрование
  // (ограниченная очередь держит использование памяти предсказуемым)
}

Медленный старт TCP и накладные расходы TLS-рукопожатия означают, что начальные загрузки медленные. Поддержание 4-8 параллельных загрузок на один источник заполняет канал, не нарушая лимиты браузера (6 соединений на источник в Chrome/Firefox).

WebAssembly для недостающих примитивов

Для Argon2id деривации ключей или ChaCha20-Poly1305 в Web Crypto нет нативной поддержки. WASM-библиотеки заполняют пробел:

  • libsodium.js предоставляет Argon2id, XChaCha20-Poly1305, crypto_secretstream
  • argon2-browser предоставляет только Argon2, но меньший размер бандла
  • @noble/hashes предоставляет чистый JS Argon2 (медленнее) с tiny-бандлом

WASM-версии достигают 60-80% нативной скорости для большинства криптографических нагрузок. Для передачи файлов деривация Argon2id за 1-2 секунды при загрузке и скачивании приемлема; деривация чистым JS за 5 секунд — нет.

Загружайте WASM динамически, чтобы не блокировать первоначальный рендер страницы:

const sodium = await import('libsodium-wrappers');
await sodium.ready;

Отображение прогресса

Долгие операции шифрования требуют обратной связи — иначе пользователи решат, что приложение зависло. Считайте зашифрованные байты и отправляйте события прогресса:

let processed = 0;
for await (const chunk of chunks) {
  await encryptChunk(chunk);
  processed += chunk.size;
  onProgress({ done: processed, total: file.size, pct: processed / file.size });
}

Ограничивайте частоту обновления UI до ~10 Гц через requestAnimationFrame или простую проверку временно́й метки; более частые обновления тратят циклы на перерисовку, которую люди всё равно не заметят.

Потолок памяти и давление GC

Каждый ArrayBuffer существует до тех пор, пока на него есть ссылки. Хранение 20 зашифрованных чанков по 4 МБ занимает ~80 МБ памяти. На мобильных браузерах с жёсткими лимитами памяти (iOS Safari ограничивает до ~200-400 МБ на вкладку) это важно.

Освобождайте ссылки своевременно:

for (let i = 0; i < chunks.length; i++) {
  const chunk = chunks[i];
  chunks[i] = null; // позволяем GC освободить
  const ct = await encrypt(chunk);
  await upload(ct);
}

Не храните полный массив зашифрованных чанков в памяти; передавайте их загрузчику потоком и освобождайте ссылки по ходу.

Реальные целевые показатели

Для передачи зашифрованного файла 1 ГБ в браузерной вкладке: время шифрования 1-3 секунды (аппаратное ускорение), время загрузки 30 секунд на соединении 300 Мбит/с, итого около 35 секунд стенного времени (в основном сеть), потребление памяти менее 50 МБ в пике при корректной потоковой обработке, UI отзывчив (основной поток не блокируется более 50 мс). Не достигайте этих показателей — и пользователи заметят. Лимит HexaTransfer в 10 ГБ достижим в браузере, потому что Web Crypto плюс чанкованный стриминг делают весь путь эффективным.

Попробуйте на hexatransfer.com — бесплатно, без регистрации, до 10 ГБ.

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

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

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