Потоковое шифрование в Node.js обычно строится вокруг идеи преобразования данных на лету без накопления всего сообщения в памяти. В связке с TweetNaCl.js это требует дополнительного уровня абстракции, поскольку библиотека предоставляет криптографические примитивы, но не предоставляет готовой потоковой модели.
Основная операция, используемая для симметричного шифрования, —
nacl.secretbox. Она работает с сообщениями фиксированной
длины, требует nonce и ключ фиксированного размера, и возвращает
зашифрованный массив байт с аутентификационным тегом.
TweetNaCl.js предоставляет два ключевых механизма, применимых в потоковом контексте:
nacl.secretbox(message, nonce, key) — симметричное
шифрование (XSalsa20-Poly1305)nacl.randomBytes(n) — генерация криптографически
стойких случайных значенийКлючевой особенностью secretbox является требование
уникальности nonce для каждого сообщения при одном ключе. В потоковой
модели это означает, что каждый блок данных должен иметь собственный
nonce.
Если нарушить уникальность nonce, безопасность полностью теряется, включая возможность восстановления исходных данных атакующим.
Node.js Streams оперируют чанками данных произвольного размера. При этом:
TweetNaCl.js ожидает “законченные сообщения”, поэтому потоковое шифрование строится через фрейминг:
Типичная структура одного зашифрованного сегмента:
[ nonce (24 bytes) ][ ciphertext (N bytes + 16 bytes MAC) ]
Где:
Такой формат позволяет последовательно восстанавливать поток без потери границ.
Node.js предоставляет stream.Transform, который идеально
подходит для реализации криптографического слоя.
Ниже приведена типовая реализация потокового шифрования:
import { Transform } from 'stream';
import nacl from 'tweetnacl';
export class SecretBoxEncryptStream extends Transform {
constructor(key) {
super();
this.key = key;
}
_transform(chunk, encoding, callback) {
try {
const nonce = nacl.randomBytes(24);
const message = new Uint8Array(chunk);
const encrypted = nacl.secretbox(message, nonce, this.key);
const output = new Uint8Array(nonce.length + encrypted.length);
output.set(nonce, 0);
output.set(encrypted, nonce.length);
this.push(Buffer.from(output));
callback();
} catch (err) {
callback(err);
}
}
}
Здесь каждый входящий chunk рассматривается как
независимое сообщение. Это упрощает реализацию, но делает поток
семантически “чанковым”, а не байтовым.
В реальных системах входящие данные редко совпадают с удобными границами. Поэтому часто применяется буферизация:
Пример подхода с фиксированным размером:
import { Transform } from 'stream';
import nacl from 'tweetnacl';
export class ChunkedSecretBoxEncryptStream extends Transform {
constructor(key, chunkSize = 1024) {
super();
this.key = key;
this.chunkSize = chunkSize;
this.buffer = Buffer.alloc(0);
}
_transform(chunk, encoding, callback) {
try {
this.buffer = Buffer.concat([this.buffer, chunk]);
while (this.buffer.length >= this.chunkSize) {
const piece = this.buffer.subarray(0, this.chunkSize);
this.buffer = this.buffer.subarray(this.chunkSize);
this.push(this.encryptChunk(piece));
}
callback();
} catch (e) {
callback(e);
}
}
_flush(callback) {
try {
if (this.buffer.length > 0) {
this.push(this.encryptChunk(this.buffer));
}
callback();
} catch (e) {
callback(e);
}
}
encryptChunk(chunk) {
const nonce = nacl.randomBytes(24);
const encrypted = nacl.secretbox(new Uint8Array(chunk), nonce, this.key);
const result = new Uint8Array(nonce.length + encrypted.length);
result.set(nonce);
result.set(encrypted, nonce.length);
return Buffer.from(result);
}
}
Такой подход делает поток более предсказуемым и удобным для декодирования.
Дешифрование требует строгого восстановления структуры:
import { Transform } from 'stream';
import nacl from 'tweetnacl';
export class SecretBoxDecryptStream extends Transform {
constructor(key) {
super();
this.key = key;
}
_transform(chunk, encoding, callback) {
try {
const data = new Uint8Array(chunk);
const nonce = data.subarray(0, 24);
const ciphertext = data.subarray(24);
const decrypted = nacl.secretbox.open(ciphertext, nonce, this.key);
if (!decrypted) {
throw new Error('Decryption failed');
}
this.push(Buffer.from(decrypted));
callback();
} catch (e) {
callback(e);
}
}
}
Критическая особенность потокового шифрования — потеря одного байта ломает весь блок. Поэтому важно учитывать:
Для повышения надёжности часто добавляют:
Практическая схема часто расширяется до:
[ 4 bytes length ][ 24 bytes nonce ][ ciphertext ]
Тогда декодер может:
Это особенно важно при работе с сетевыми потоками, где
chunk не гарантирует целостность сообщения.
При использовании net.Socket в Node.js важно помнить,
что TCP:
Поэтому слой Transform должен самостоятельно
реализовывать:
Типичная ошибка — предположение, что chunk =
зашифрованный блок.
TweetNaCl.js работает с Uint8Array, а Node.js Streams —
с Buffer. Это требует постоянных преобразований:
Buffer -> Uint8Array при шифрованииUint8Array -> Buffer при выводеДополнительно часто используется tweetnacl-util:
encodeUTF8decodeUTF8encodeBase64decodeBase64Однако для потоков base64 нежелателен, поскольку:
Ключ в secretbox:
Nonce:
Случайная генерация проще, но инкрементальная схема уменьшает зависимость от RNG:
this.counter = 0;
function nextNonce() {
const nonce = new Uint8Array(24);
const view = new DataView(nonce.buffer);
view.setBigUint64(16, BigInt(this.counter++), true);
return nonce;
}
Такой подход часто используется в высоконагруженных системах.
Основные факторы влияния:
secretboxBuffer ↔︎ Uint8ArrayОптимальная стратегия:
Несмотря на удобство, существуют ограничения:
В задачах с высокими требованиями к throughput иногда переходят на
libsodium-native или Node.js crypto stream cipher режимы,
но принцип построения потокового слоя остаётся тем же: фрейминг + nonce
+ Transform stream.
В реальных системах часто используется гибрид:
secretbox для данныхbox)В потоках это выражается как:
Такая схема уменьшает риск компрометации длинных сессий.
При дешифровании важны сценарии:
Правильная реакция — немедленная остановка обработки потока, поскольку дальнейшая расшифровка становится недостоверной.
Типовая архитектура выглядит как цепочка:
Readable Stream
↓
Encrypt Transform (framing + secretbox)
↓
Transport (file / socket / websocket)
↓
Decrypt Transform (parse + secretbox.open)
↓
Writable Stream
Каждый слой выполняет строго ограниченную задачу, а вся безопасность определяется корректностью: