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