Web Crypto API не предоставляет потокового шифрования в классическом
смысле. Все операции crypto.subtle.encrypt() и
crypto.subtle.decrypt() работают с целостными буферами
данных (ArrayBuffer), что создаёт фундаментальное
ограничение при работе с файлами, объёмами в сотни мегабайт и выше.
Основная проблема заключается в том, что невозможно «дополнить» уже начатую операцию шифрования новыми данными. Каждый вызов криптографической функции обрабатывает только тот блок, который был передан на вход. Это делает необходимым ручное разбиение данных на части — чанки.
При работе с большими объёмами данных появляются ключевые требования:
Чанкирование — это стратегия, при которой исходный массив данных разбивается на фиксированные или динамические блоки, каждый из которых шифруется отдельно.
Типичная схема:
ArrayBufferПростейшая модель разбиения:
function chunkArrayBuffer(buffer, chunkSize) {
const chunks = [];
for (let i = 0; i < buffer.byteLength; i += chunkSize) {
chunks.push(buffer.slice(i, i + chunkSize));
}
return chunks;
}
Чанк размером 64–256 KB часто является компромиссом между производительностью и нагрузкой на память.
При шифровании чанков критически важно выбрать корректный режим, так как не все режимы одинаково безопасны при раздельной обработке данных.
Наиболее безопасный вариант для чанков — AES-GCM. Он обеспечивает:
Каждый чанк должен иметь уникальный IV.
Потоковый режим, удобный для чанков, но без встроенной проверки целостности. Требует дополнительного HMAC.
Требует строгой цепочки IV между блоками и плохо подходит для независимых чанков.
Ключевая ошибка при чанкировании — повторное использование IV. Для AES-GCM это критическая уязвимость.
Подходы к генерации IV:
function createIV() {
return crypto.getRandomValues(new Uint8Array(12));
}
Недостаток — необходимость хранения IV для каждого блока.
Более контролируемый подход:
function deriveIV(baseIV, index) {
const iv = new Uint8Array(baseIV);
iv[11] ^= index & 0xff;
iv[10] ^= (index >> 8) & 0xff;
return iv;
}
Преимущество — воспроизводимость.
Каждый зашифрованный блок должен содержать:
Типичная структура:
[chunkIndex | iv | ciphertext | tag]
Для хранения часто используют бинарный формат
Uint8Array.
Базовый процесс шифрования:
async function encryptChunk(key, chunk, iv) {
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv
},
key,
chunk
);
return new Uint8Array(encrypted);
}
Полный процесс обработки файла:
async function encryptLargeData(key, buffer, chunkSize = 64 * 1024) {
const chunks = chunkArrayBuffer(buffer, chunkSize);
const result = [];
for (let i = 0; i < chunks.length; i++) {
const iv = deriveIV(new Uint8Array(12), i);
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv
},
key,
chunks[i]
);
result.push({
index: i,
iv,
data: new Uint8Array(encrypted)
});
}
return result;
}
Дешифрование требует строгого соблюдения порядка чанков и идентичности параметров шифрования.
async function decryptChunk(key, chunk, iv) {
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv
},
key,
chunk
);
return new Uint8Array(decrypted);
}
Сборка данных:
async function decryptLargeData(key, encryptedChunks) {
encryptedChunks.sort((a, b) => a.index - b.index);
const buffers = [];
for (const chunk of encryptedChunks) {
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: chunk.iv
},
key,
chunk.data
);
buffers.push(new Uint8Array(decrypted));
}
return mergeBuffers(buffers);
}
Слияние:
function mergeBuffers(buffers) {
const totalLength = buffers.reduce((acc, b) => acc + b.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const buf of buffers) {
result.set(buf, offset);
offset += buf.length;
}
return result;
}
При работе с большими файлами предпочтительно использовать
ReadableStream и TransformStream, чтобы
избежать загрузки всего файла в память.
Архитектура:
Пример трансформирующего потока:
class EncryptStream extends TransformStream {
constructor(key, chunkSize = 64 * 1024) {
let index = 0;
const ivBase = crypto.getRandomValues(new Uint8Array(12));
super({
async transform(chunk, controller) {
const iv = deriveIV(ivBase, index++);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
chunk
);
controller.enqueue(new Uint8Array(encrypted));
}
});
}
}
Такой подход позволяет обрабатывать данные без накопления в памяти.
При разбиении данных вручную возникает важный нюанс: криптографические алгоритмы работают с блоками фиксированного размера внутри, но логические чанки не должны пересекать смысловые границы.
Ошибки возникают при:
В таких случаях требуется:
Для восстановления данных недостаточно только ciphertext. Необходима метаинформация:
Пример метаданных:
{
"algorithm": "AES-GCM",
"chunkSize": 65536,
"chunks": [
{ "index": 0, "iv": "...", "length": 65536 },
{ "index": 1, "iv": "...", "length": 65536 }
]
}
На практике наиболее распространены следующие проблемы:
Даже один повтор IV в AES-GCM компрометирует ключ.
Без индексов восстановление становится невозможным.
Последний чанк часто меньше остальных и требует отдельной обработки.
Удаление tag делает GCM режим фактически уязвимым.
Для ускорения обработки больших данных применяются:
Пример параллельной обработки:
const promises = chunks.map(async (chunk, i) => {
const iv = deriveIV(baseIV, i);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
chunk
);
return { i, encrypted, iv };
});
Чанкирование в Web Crypto API фактически является пользовательским уровнем поверх криптографических primitives. Это означает, что:
В более сложных системах чанки часто комбинируются с:
Такая комбинация позволяет приблизиться к поведению потокового шифрования без его нативной поддержки в Web Crypto API