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

Прогрессивное шифрование больших файлов: потоковое шифрование

Шифруйте большие файлы прогрессивно через потоковые API. Обрабатывайте многогигабайтные файлы без нехватки памяти.

Прогрессивное (потоковое) шифрование обрабатывает файл чанк за чанком, никогда не загружая полезную нагрузку целиком в память. Для загрузки 10 ГБ в браузере это разница между работающим приложением и падающим. Паттерн: читать чанк через File.stream(), шифровать его с AES-256-GCM уникальным nonce, передавать шифротекст напрямую в поток загрузки через fetch с телом ReadableStream, освобождать буфер и переходить к следующему. Потребление памяти остаётся ограниченным на уровне 4-16 МБ независимо от размера файла. crypto_secretstream_xchacha20poly1305 из libsodium добавляет корректную семантику потокового AEAD, включая обнаружение усечения. Вот конкретная реализация с числами, которые воспроизводятся на реальном железе.

Почему буферизованное шифрование не работает

FileReader.readAsArrayBuffer(file) на файле 10 ГБ выделяет 10 ГБ памяти браузера. На десктопном Chrome с 32 ГБ оперативной памяти это может сработать. На мобильном Safari с лимитом 400 МБ на вкладку — произойдёт сбой до завершения. В Firefox ArrayBuffer свыше 2 ГБ ударяется о внутренние ограничения и выбрасывает RangeError.

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

Паттерн потоковой обработки

async function streamEncrypt(file, key, uploadURL) {
  const CHUNK_SIZE = 4 * 1024 * 1024; // 4 МБ
  const reader = file.stream().getReader();
  let chunkIndex = 0;
  let buffer = new Uint8Array(0);

  const uploadStream = new ReadableStream({
    async pull(controller) {
      while (buffer.length < CHUNK_SIZE) {
        const { done, value } = await reader.read();
        if (done) {
          if (buffer.length > 0) {
            await enqueueEncrypted(controller, buffer, chunkIndex++, key);
          }
          controller.close();
          return;
        }
        const newBuf = new Uint8Array(buffer.length + value.length);
        newBuf.set(buffer, 0);
        newBuf.set(value, buffer.length);
        buffer = newBuf;
      }
      const chunk = buffer.subarray(0, CHUNK_SIZE);
      buffer = buffer.subarray(CHUNK_SIZE);
      await enqueueEncrypted(controller, chunk, chunkIndex++, key);
    }
  });

  await fetch(uploadURL, {
    method: "POST",
    body: uploadStream,
    duplex: "half",
    headers: { "Content-Type": "application/octet-stream" },
  });
}

async function enqueueEncrypted(controller, chunk, index, key) {
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setBigUint64(4, BigInt(index));
  const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, chunk);
  controller.enqueue(new Uint8Array(ct));
}

Два ключевых API: File.stream() даёт ReadableStream с содержимым файла; fetch с телом ReadableStream передаёт загрузку потоком без буферизации всего тела. duplex: "half" обязателен в Chrome 105+ для потоковых тел запросов.

Потребление памяти: в любой момент — один исходный чанк, один буферизованный остаток, один зашифрованный чанк. Пик ~12-16 МБ для чанка 4 МБ.

Управление nonce в потоках

Каждому чанку нужен уникальный nonce. Три подхода:

На основе счётчика: встраивайте индекс чанка в 96-битный nonce. Устанавливайте старшие 32 бита в случайный префикс (для исключения коллизий между файлами, использующими один ключ), младшие 64 бита — в индекс чанка.

const noncePrefix = crypto.getRandomValues(new Uint32Array(1));
function makeNonce(chunkIndex) {
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setUint32(0, noncePrefix[0]);
  new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex));
  return iv;
}

Случайный для каждого чанка: crypto.getRandomValues(new Uint8Array(12)). Безопасно для ключей на файл; коллизии дней рождений между чанками достигаются ~2^48. Храните nonce рядом с шифротекстом каждого чанка.

Производный через HKDF: используйте HKDF для вывода ключей на чанк, затем используйте фиксированный nonce. Избыточно для большинства случаев.

Для свежего ключа на файл подход на основе счётчика проще всего и не требует хранения отдельного nonce для каждого чанка.

Атаки усечения и их обнаружение

Критический пробел в наивном чанкованном AES-GCM: атакующий может удалить завершающие чанки, и каждый оставшийся чанк дешифруется корректно. Для обнаружения необходима криптографическая связь чанков.

Вариант 1: включайте общее количество чанков в AAD каждого чанка. Получатель проверяет, что количество совпадает с полученным.

const aad = new TextEncoder().encode(JSON.stringify({
  totalChunks,
  fileSize: file.size,
}));
const ct = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv, additionalData: aad },
  key,
  chunk
);

Вариант 2: используйте crypto_secretstream_xchacha20poly1305 из libsodium. Он криптографически связывает чанки и выдаёт маркер TAG_FINAL, который получатель проверяет:

const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
// Для каждого чанка push с TAG_MESSAGE
// Для последнего чанка push с TAG_FINAL
const lastCt = sodium.crypto_secretstream_xchacha20poly1305_push(
  state, lastChunk, null,
  sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
);

При дешифровании вызовы pull получателя проверяют цепочку и обнаруживают отсутствующие хвостовые чанки. Это наиболее чистый вариант, если вы готовы поставлять libsodium.js.

Потоковое дешифрование на стороне получателя

Симметричный паттерн на стороне получателя:

async function streamDecrypt(downloadURL, key, onChunk) {
  const response = await fetch(downloadURL);
  const reader = response.body.getReader();
  let buffer = new Uint8Array(0);
  let chunkIndex = 0;
  const ENCRYPTED_CHUNK_SIZE = 4 * 1024 * 1024 + 16; // плюс GCM тег

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const newBuf = new Uint8Array(buffer.length + value.length);
    newBuf.set(buffer);
    newBuf.set(value, buffer.length);
    buffer = newBuf;
    while (buffer.length >= ENCRYPTED_CHUNK_SIZE) {
      const ct = buffer.subarray(0, ENCRYPTED_CHUNK_SIZE);
      buffer = buffer.subarray(ENCRYPTED_CHUNK_SIZE);
      const iv = makeNonce(chunkIndex++);
      const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
      onChunk(new Uint8Array(pt));
    }
  }
  // Обрабатываем финальный частичный чанк
  if (buffer.length > 0) {
    const iv = makeNonce(chunkIndex);
    const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, buffer);
    onChunk(new Uint8Array(pt));
  }
}

На стороне получателя колбэки onChunk могут передавать расшифрованные байты в File System Access API для прямой записи на диск или конкатенировать в Blob для нативной загрузки браузером.

Запись на диск через File System Access API

Для очень больших загрузок загрузка полного расшифрованного результата в Blob сводит на нет смысл потоковой обработки. File System Access API (Chrome 86+, частично Safari через OPFS) позволяет получателю выбрать локальный файл и записывать чанки напрямую:

const handle = await window.showSaveFilePicker({
  suggestedName: "decrypted-file",
});
const writable = await handle.createWritable();

await streamDecrypt(url, key, async (chunk) => {
  await writable.write(chunk);
});
await writable.close();

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

Firefox пока не поддерживает showSaveFilePicker на десктопе. Используйте запасной вариант: сборка Blob в памяти (приемлемо для файлов до нескольких сотен МБ) или Origin Private File System для многогигабайтных сценариев в Firefox.

Потоковая загрузка через fetch

Chrome 105+ и Firefox 127+ поддерживают потоковые тела запросов с duplex: "half". До этого загрузки должны были быть либо полными буферами, либо multipart с вручную управляемой chunked transfer encoding.

Для multipart-загрузок, совместимых с S3, каждая часть загружается отдельным запросом. Разбивайте зашифрованный поток на части по 5-25 МБ (минимальный размер части S3 — 5 МБ, максимальный — 5 ГБ) и завершайте финальным вызовом CompleteMultipartUpload. Это работает во всех браузерах и бесплатно даёт возможность возобновления.

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

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

let processed = 0;
const onChunk = (chunkSize) => {
  processed += chunkSize;
  updateProgressBar(processed / file.size);
};

Ограничивайте обновления прогресса до 10-20 Гц через requestAnimationFrame, чтобы избежать лишних перерисовок. На файлах 10 ГБ при скорости обработки 100 МБ/с это всё равно 100 сырых событий в секунду — значительно больше, чем нужно UI.

Бенчмарки на файле 10 ГБ

На MacBook Pro 2024 (M3 Max) с быстрым SSD: сырое чтение с диска через File.stream() — 2.5 ГБ/с, AES-256-GCM через Web Crypto — 1.7 ГБ/с, комбинированный конвейер — 1.1 ГБ/с (ограничен последовательной цепочкой), загрузка по гигабитному Ethernet — 115 МБ/с (ограничена сетью), пик памяти — 14 МБ независимо от размера файла. Мобильные числа примерно на 30-50% ниже десктопных. Файл 10 ГБ загружается за ~90 секунд на гигабитном соединении, ~15 минут на типичном домашнем подключении. Шифрование не является узким местом — им является сеть.

Восстановление после ошибок

Сетевые прерывания при загрузке 10 ГБ — обычное дело. Стратегии:

  • Возобновляемые загрузки через multipart: каждая часть независима; перезагружайте только неудавшуюся часть.
  • Протокол tus: открытый стандарт возобновляемых загрузок, поддерживаемый компаниями вроде Vimeo; нативный для потоков.
  • Держите дескриптор исходного файла открытым: если File.slice повторяем, перезапускайте с последнего успешного чанка.

Лимит HexaTransfer в 10 ГБ достижим в одной браузерной вкладке, потому что этот потоковый конвейер держит память ограниченной и корректно обрабатывает прерывания через multipart-повторы. Тот же паттерн масштабируется на бо́льшие лимиты при поддержке бэкенда.

Коротко

Не выделяйте весь файл. Читайте чанками, шифруйте чанками, загружайте чанками, освобождайте каждый чанк по ходу. Криптографически связывайте чанки через AAD или потоковый AEAD для защиты от усечения. Добавляйте прогресс-бар везде. Тестируйте на мобильных, а не только на десктопе.

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

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

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

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