Шифрование потоков и файлов в Node.js

Библиотека TweetNaCl.js реализует набор криптографических примитивов NaCl (Networking and Cryptography library), ориентированных на простоту и безопасность. В контексте работы с файлами и потоками в Node.js ключевым инструментом становится nacl.secretbox, обеспечивающий симметричное шифрование с аутентификацией.

Главная особенность NaCl-подхода — отказ от «магических режимов» и акцент на явное управление nonce, ключами и целостностью данных.

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


Symmetric encryption в TweetNaCl.js

Основной механизм симметричного шифрования:

nacl.secretbox(message, nonce, key)

Расшифровка:

  • message — Uint8Array с данными
  • nonce — 24-байтовое уникальное значение для каждого сообщения
  • key — 32-байтовый секретный ключ

Дешифрование:

nacl.secretbox.open(box, nonce, key)

Если nonce повторяется с тем же ключом, безопасность полностью нарушается. В потоковом режиме это критически важно.


Генерация ключей и nonce

Для файлового шифрования используются случайные ключи и начальный nonce:

const nacl = require('tweetnacl');
nacl.util = require('tweetnacl-util');

const key = nacl.randomBytes(32);
const nonce = nacl.randomBytes(24);

Nonce не должен быть случайным для каждого блока при потоковой модели. Он должен изменяться предсказуемо (например, инкрементироваться).


Стратегия потокового шифрования

Так как secretbox не поддерживает поток напрямую, применяется блочная схема:

  1. Файл разбивается на чанки фиксированного размера (например, 16 KB)
  2. Каждый чанк шифруется отдельно
  3. Для каждого чанка используется уникальный nonce
  4. Все зашифрованные блоки записываются в поток

Инкремент nonce для потоков

Nonce — 24 байта. Его можно трактовать как число и увеличивать:

function incrementNonce(nonce) {
  const result = new Uint8Array(nonce);
  for (let i = 0; i < result.length; i++) {
    result[i]++;
    if (result[i] !== 0) break;
  }
  return result;
}

Такой подход обеспечивает уникальность nonce для каждого блока.


Шифрование файлов через fs.createReadStream

Стандартный поток чтения файла:

const fs = require('fs');
const nacl = require('tweetnacl');
nacl.util = require('tweetnacl-util');

const key = nacl.randomBytes(32);
let nonce = nacl.randomBytes(24);

const input = fs.createReadStream('input.txt', {
  highWaterMark: 16 * 1024
});

const output = fs.createWriteStream('output.enc');

Потоковое шифрование чанков

Основная логика обработки:

input.on('data', (chunk) => {
  const uint8Chunk = new Uint8Array(chunk);

  const encrypted = nacl.secretbox(uint8Chunk, nonce, key);

  output.write(Buffer.from(encrypted));

  nonce = incrementNonce(nonce);
});

Дешифрование потокового файла

Процесс обратный:

const input = fs.createReadStream('output.enc', {
  highWaterMark: 16 * 1024 + 16
});

const output = fs.createWriteStream('decrypted.txt');

let nonce = initialNonce;

input.on('data', (chunk) => {
  const decrypted = nacl.secretbox.open(
    new Uint8Array(chunk),
    nonce,
    key
  );

  if (!decrypted) {
    throw new Error('Ошибка дешифрования');
  }

  output.write(Buffer.from(decrypted));

  nonce = incrementNonce(nonce);
});

Учет размера блоков

secretbox добавляет overhead в 16 байт (MAC). Поэтому:

  • исходный chunk: 16 KB
  • зашифрованный: 16 KB + 16 bytes

При чтении важно учитывать это расхождение, иначе поток разъедется.


Формат хранения зашифрованного файла

Практическая структура:

[nonce (24 bytes)][cipher chunk 1][cipher chunk 2]...

Однако при потоковом подходе nonce обычно хранится отдельно (например, в заголовке файла или метаданных).

Пример записи заголовка:

output.write(Buffer.from(nonce));

Потоковое шифрование через Transform stream

Более правильная архитектура в Node.js — использование Transform:

const { Transform } = require('stream');

class EncryptStream extends Transform {
  constructor(key, nonce) {
    super();
    this.key = key;
    this.nonce = nonce;
  }

  _transform(chunk, encoding, callback) {
    const encrypted = nacl.secretbox(
      new Uint8Array(chunk),
      this.nonce,
      this.key
    );

    this.push(Buffer.from(encrypted));
    this.nonce = incrementNonce(this.nonce);

    callback();
  }
}

Использование:

fs.createReadStream('input.txt')
  .pipe(new EncryptStream(key, nonce))
  .pipe(fs.createWriteStream('output.enc'));

Потоковое дешифрование через Transform stream

class DecryptStream extends Transform {
  constructor(key, nonce) {
    super();
    this.key = key;
    this.nonce = nonce;
  }

  _transform(chunk, encoding, callback) {
    const decrypted = nacl.secretbox.open(
      new Uint8Array(chunk),
      this.nonce,
      this.key
    );

    if (!decrypted) {
      return callback(new Error('Decryption failed'));
    }

    this.push(Buffer.from(decrypted));
    this.nonce = incrementNonce(this.nonce);

    callback();
  }
}

Обработка больших файлов

При работе с файлами большого размера критично учитывать:

  • backpressure потоков Node.js
  • одинаковый размер чанков
  • контроль памяти

Transform автоматически управляет backpressure, что делает его предпочтительным вариантом.


Ошибки синхронизации nonce

Наиболее частые проблемы:

  • несовпадение порядка чанков
  • потеря или дублирование блока
  • неправильное восстановление nonce

Любое нарушение последовательности приводит к невозможности расшифровки последующих блоков.


Альтернатива: использование xsalsa20-poly1305 потоков

TweetNaCl.js не предоставляет полноценного streaming API. Поэтому для более сложных задач часто применяется:

  • libsodium-wrappers
  • sodium-native
  • собственная реализация поверх secretbox

Работа с бинарными данными и Buffer

Node.js Buffer требует явного преобразования:

const uint8 = new Uint8Array(buffer);
const back = Buffer.from(uint8);

Ошибка в этом месте часто приводит к повреждению данных при дешифровке.


Сериализация зашифрованных данных

При необходимости хранения в JSON:

const encoded = nacl.util.encodeBase64(encrypted);
const decoded = nacl.util.decodeBase64(encoded);

Base64 увеличивает размер примерно на 33%, но упрощает транспортировку.


Контроль целостности данных

secretbox уже включает Poly1305 MAC, что обеспечивает:

  • защиту от модификации
  • обнаружение повреждений
  • проверку корректности ключа и nonce

При ошибке secretbox.open возвращает null.


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

Основные ограничения:

  • CPU-bound операция (AES не используется, но NaCl всё равно вычислительно затратен)
  • частые вызовы secretbox
  • GC pressure при работе с Uint8Array

Оптимизация достигается через:

  • увеличение chunk size (например, 64 KB)
  • минимизацию преобразований Buffer ↔︎ Uint8Array
  • использование Transform streams

Типовая архитектура файлового шифрования

Практически используемая схема:

  • генерация ключа (32 bytes)
  • генерация стартового nonce (24 bytes)
  • запись nonce в заголовок файла
  • потоковое шифрование через Transform
  • последовательное инкрементирование nonce
  • запись блоков в файл

Такая структура обеспечивает детерминированное восстановление данных при наличии ключа и начального nonce.


Частые ошибки при реализации

  • повторное использование nonce
  • несоответствие размера чанков
  • смешивание Buffer и Uint8Array без контроля
  • попытка параллельного шифрования без синхронизации nonce
  • отсутствие хранения начального nonce

Эти ошибки приводят к полной невозможности восстановления данных без явных сообщений об ошибках.


Практическое применение в Node.js системах

Потоковое шифрование на основе TweetNaCl.js применяется в:

  • локальном шифровании бэкапов
  • защищённой передаче файлов через HTTP/WebSocket
  • клиент-серверных системах с end-to-end encryption
  • шифровании логов и архивов

При этом архитектура остаётся простой: поток → шифратор → файл, без промежуточных буферов и сложных криптографических режимов.