Библиотеки TweetNaCl.js и nacl.js реализуют примитивы NaCl,
ориентированные на безопасное симметричное и асимметричное шифрование.
Основной высокоуровневый механизм для конфиденциальных данных —
secretbox (XSalsa20 + Poly1305). Он предназначен для
шифрования отдельных сообщений фиксированной логики: одно сообщение →
один зашифрованный блок.
Внутри secretbox существует фундаментальное ограничение:
размер одного сообщения не предназначен для бесконечно больших потоков
данных. Хотя технически допустимы довольно крупные буферы, работа с
файлами, видеопотоками или архивами требует иной архитектуры — разбиения
данных на блоки.
Шифрование в nacl.secretbox устроено следующим
образом:
Каждое сообщение шифруется независимо. Это означает, что безопасность полностью зависит от уникальности nonce.
const key = nacl.randomBytes(32);
const nonce = nacl.randomBytes(24);
const message = new TextEncoder().encode("секретные данные");
const boxed = nacl.secretbox(message, nonce, key);
const opened = nacl.secretbox.open(boxed, nonce, key);
Проблема возникает, когда message перестаёт быть
“сообщением” и становится потоком данных.
При попытке зашифровать файл целиком возникают ограничения:
Особенно критично второе: повтор nonce + один и тот же ключ → катастрофическая потеря безопасности в XSalsa20.
Основная идея: представить большой файл как последовательность независимых сообщений фиксированного размера.
Файл разбивается на блоки:
Nonce должен быть уникальным для каждого блока. Существует несколько подходов.
Самый распространённый способ — использовать часть nonce как счётчик.
function incrementNonce(nonce) {
for (let i = nonce.length - 1; i >= 0; i--) {
nonce[i]++;
if (nonce[i] !== 0) break;
}
}
Инициализация:
const baseNonce = nacl.randomBytes(16);
let counter = 0;
function makeNonce() {
const nonce = new Uint8Array(24);
nonce.set(baseNonce, 0);
const view = new DataView(nonce.buffer);
view.setBigUint64(16, BigInt(counter), true);
counter++;
return nonce;
}
Преимущество: отсутствие мутаций, удобство параллельной обработки.
Ключевой этап — разрезание входного буфера.
function chunkBuffer(buffer, chunkSize) {
const chunks = [];
for (let i = 0; i < buffer.length; i += chunkSize) {
chunks.push(buffer.slice(i, i + chunkSize));
}
return chunks;
}
Типичные размеры:
Полный процесс шифрования большого массива данных:
function encryptLargeData(data, key) {
const chunkSize = 64 * 1024;
const chunks = chunkBuffer(data, chunkSize);
const baseNonce = nacl.randomBytes(16);
let counter = 0;
const encryptedChunks = [];
for (const chunk of chunks) {
const nonce = new Uint8Array(24);
nonce.set(baseNonce, 0);
const view = new DataView(nonce.buffer);
view.setBigUint64(16, BigInt(counter), true);
const encrypted = nacl.secretbox(chunk, nonce, key);
encryptedChunks.push(encrypted);
counter++;
}
return {
chunks: encryptedChunks,
baseNonce
};
}
При разбиении важно сохранять метаданные:
Типичная структура:
[header][chunk1][chunk2][chunk3]...
Где header содержит:
{
"algorithm": "secretbox-xsalsa20-poly1305",
"chunkSize": 65536,
"noncePrefix": "...",
"totalChunks": 120
}
Дешифрование выполняется симметрично:
function decryptLargeData(chunks, key, baseNonce) {
const result = [];
for (let i = 0; i < chunks.length; i++) {
const nonce = new Uint8Array(24);
nonce.set(baseNonce, 0);
const view = new DataView(nonce.buffer);
view.setBigUint64(16, BigInt(i), true);
const decrypted = nacl.secretbox.open(chunks[i], nonce, key);
if (!decrypted) {
throw new Error("Ошибка аутентификации блока");
}
result.push(decrypted);
}
return concatBuffers(result);
}
function concatBuffers(buffers) {
let totalLength = 0;
for (const b of buffers) {
totalLength += b.length;
}
const result = new Uint8Array(totalLength);
let offset = 0;
for (const b of buffers) {
result.set(b, offset);
offset += b.length;
}
return result;
}
Для больших файлов предпочтительнее не хранить всё в памяти, а обрабатывать потоками:
Пример концепции:
чтение блока → шифрование → запись → следующий блок
В браузере можно построить transform stream:
class EncryptStream extends TransformStream {
constructor(key) {
let counter = 0;
const baseNonce = nacl.randomBytes(16);
super({
transform(chunk, controller) {
const nonce = new Uint8Array(24);
nonce.set(baseNonce, 0);
const view = new DataView(nonce.buffer);
view.setBigUint64(16, BigInt(counter++), true);
const encrypted = nacl.secretbox(chunk, nonce, key);
controller.enqueue(encrypted);
}
});
}
}
Повтор nonce при одном ключе полностью разрушает безопасность XSalsa20.
Каждый блок автономен, но порядок важен. Потеря индексации приводит к невозможности восстановления данных.
Если на этапе шифрования и дешифрования используются разные размеры блоков без явной метадаты — восстановление невозможно.
Хотя secretbox уже включает Poly1305-аутентификацию, на
уровне всей структуры часто добавляют:
Пример добавления контроля:
const hash = nacl.hash(fullData);
Переподсчёт nonce через мутацию быстрее, чем создание нового массива.
Избегать TextEncoder/TextDecoder внутри циклов.
Объединение нескольких блоков перед записью снижает I/O нагрузку.
Однако модель блочного шифрования идентична: всё строится поверх
secretbox.
При увеличении объёмов данных до гигабайтов добавляются дополнительные уровни:
async function encryptParallel(chunks, key) {
return Promise.all(
chunks.map((chunk, i) => encryptChunk(chunk, key, i))
);
}
Проблема: nonce должен оставаться детерминированным или централизованным.
Система блочного шифрования на базе TweetNaCl.js обычно включает:
Такая модель превращает secretbox из “шифрования
сообщения” в полноценный механизм для работы с произвольными объёмами
данных без изменения криптографического ядра.