Потоковое шифрование в Node.js Streams

Потоковое шифрование в 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
  • результат сериализуется в поток байтов

Формат зашифрованного блока

Типичная структура одного зашифрованного сегмента:

[ nonce (24 bytes) ][ ciphertext (N bytes + 16 bytes MAC) ]

Где:

  • 24 байта — nonce для XSalsa20
  • ciphertext включает исходные данные + 16 байт аутентификационного тега Poly1305

Такой формат позволяет последовательно восстанавливать поток без потери границ.

Реализация Transform stream для шифрования

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);
  }
}

Такой подход делает поток более предсказуемым и удобным для декодирования.

Реализация дешифрования потока

Дешифрование требует строгого восстановления структуры:

  • первые 24 байта — nonce
  • остальные байты — ciphertext
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);
    }
  }
}

Вопрос границ и целостности данных

Критическая особенность потокового шифрования — потеря одного байта ломает весь блок. Поэтому важно учитывать:

  • поток должен сохранять целостность блоков
  • нельзя смешивать разные зашифрованные сегменты
  • транспорт (TCP, файлы, WebSocket) должен гарантировать доставку без искажений

Для повышения надёжности часто добавляют:

  • длину блока перед данными
  • версионирование формата
  • контрольные суммы на уровне приложения

Формирование самодостаточного бинарного протокола

Практическая схема часто расширяется до:

[ 4 bytes length ][ 24 bytes nonce ][ ciphertext ]

Тогда декодер может:

  • читать длину
  • ожидать полный блок
  • обрабатывать поток частично пришедших данных

Это особенно важно при работе с сетевыми потоками, где chunk не гарантирует целостность сообщения.

Потоковое шифрование поверх TCP

При использовании net.Socket в Node.js важно помнить, что TCP:

  • не сохраняет границы сообщений
  • может дробить или объединять данные

Поэтому слой Transform должен самостоятельно реализовывать:

  • буферизацию входящих байтов
  • восстановление структуры блока
  • проверку целостности

Типичная ошибка — предположение, что chunk = зашифрованный блок.

Использование TweetNaCl.js и кодировка данных

TweetNaCl.js работает с Uint8Array, а Node.js Streams — с Buffer. Это требует постоянных преобразований:

  • Buffer -> Uint8Array при шифровании
  • Uint8Array -> Buffer при выводе

Дополнительно часто используется tweetnacl-util:

  • encodeUTF8
  • decodeUTF8
  • encodeBase64
  • decodeBase64

Однако для потоков base64 нежелателен, поскольку:

  • увеличивает размер данных
  • ломает бинарную структуру

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

Ключ в secretbox:

  • фиксированной длины (32 байта)
  • должен быть одинаковым на стороне шифрования и дешифрования

Nonce:

  • всегда 24 байта
  • должен быть уникальным
  • может генерироваться случайно или инкрементально

Случайная генерация проще, но инкрементальная схема уменьшает зависимость от 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;
}

Такой подход часто используется в высоконагруженных системах.

Производительность потокового шифрования

Основные факторы влияния:

  • размер чанков (слишком мелкие → overhead nonce и MAC)
  • частота вызовов secretbox
  • копирование Buffer ↔︎ Uint8Array

Оптимальная стратегия:

  • 1–64 KB на блок
  • минимизация аллокаций
  • переиспользование буферов при высоких нагрузках

Ограничения подхода TweetNaCl.js в потоках

Несмотря на удобство, существуют ограничения:

  • отсутствие встроенного streaming API
  • необходимость ручного управления nonce
  • отсутствие AEAD streaming режима
  • фиксированный overhead (MAC + nonce)

В задачах с высокими требованиями к throughput иногда переходят на libsodium-native или Node.js crypto stream cipher режимы, но принцип построения потокового слоя остаётся тем же: фрейминг + nonce + Transform stream.

Комбинированные схемы шифрования

В реальных системах часто используется гибрид:

  • secretbox для данных
  • отдельный канал для ключевого обмена (например, box)
  • периодическая ротация ключей

В потоках это выражается как:

  • сегментация потока по времени или размеру
  • пересоздание ключа
  • смена nonce space

Такая схема уменьшает риск компрометации длинных сессий.

Обработка ошибок в криптографических потоках

При дешифровании важны сценарии:

  • повреждённый блок
  • неверный ключ
  • повтор nonce (критическая ошибка)
  • обрезанный поток

Правильная реакция — немедленная остановка обработки потока, поскольку дальнейшая расшифровка становится недостоверной.

Итоговая структура криптографического слоя в Streams

Типовая архитектура выглядит как цепочка:

Readable Stream
   ↓
Encrypt Transform (framing + secretbox)
   ↓
Transport (file / socket / websocket)
   ↓
Decrypt Transform (parse + secretbox.open)
   ↓
Writable Stream

Каждый слой выполняет строго ограниченную задачу, а вся безопасность определяется корректностью:

  • nonce management
  • integrity framing
  • отсутствие повторов блоков