Работа с WebSocket: шифрование сообщений на лету

WebSocket обеспечивает двусторонний канал передачи данных в режиме реального времени, однако сам по себе не гарантирует конфиденциальность содержимого сообщений. Даже при использовании WSS (WebSocket Secure), криптографическая защита заканчивается на уровне транспортного канала. Данные внутри протокола остаются открытыми для серверной логики и промежуточной обработки.

Использование TweetNaCl.js или nacl.js позволяет выстроить дополнительный слой шифрования поверх WebSocket, обеспечивая сквозную защиту сообщений между клиентами.


Криптографическая модель TweetNaCl.js

TweetNaCl.js реализует упрощённую и безопасную криптографию на основе NaCl (Networking and Cryptography library). Основной примитив для обмена сообщениями:

  • nacl.box — асимметричное шифрование
  • nacl.box.keyPair() — генерация ключевой пары
  • nacl.randomBytes() — генерация nonce
  • nacl.box.before() — предварительное вычисление общего ключа

Основная модель:

  • У каждой стороны есть публичный и приватный ключ
  • Общий секрет вычисляется один раз
  • Далее используется симметричное шифрование сообщений

Инициализация ключей и установление защищённого канала

Перед началом обмена через WebSocket создаётся криптографическая идентичность клиента.

import nacl from "tweetnacl";
import naclUtil from "tweetnacl-util";

const keyPair = nacl.box.keyPair();

const publicKey = keyPair.publicKey;
const secretKey = keyPair.secretKey;

Публичный ключ передаётся второй стороне через WebSocket в открытом виде:

socket.send(JSON.stringify({
  type: "init",
  publicKey: naclUtil.encodeBase64(publicKey)
}));

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

После обмена публичными ключами вычисляется shared secret:

const sharedKey = nacl.box.before(
  remotePublicKey,
  secretKey
);

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


Шифрование сообщений перед отправкой

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

function encryptMessage(message, sharedKey) {
  const nonce = nacl.randomBytes(nacl.box.nonceLength);

  const messageUint8 = naclUtil.decodeUTF8(message);

  const box = nacl.box.after(
    messageUint8,
    nonce,
    sharedKey
  );

  return {
    nonce: naclUtil.encodeBase64(nonce),
    payload: naclUtil.encodeBase64(box)
  };
}

Отправка через WebSocket:

const encrypted = encryptMessage("hello", sharedKey);

socket.send(JSON.stringify({
  type: "message",
  data: encrypted
}));

Расшифрование входящих сообщений

При получении данных выполняется обратная операция:

function decryptMessage(encrypted, sharedKey) {
  const nonce = naclUtil.decodeBase64(encrypted.nonce);
  const box = naclUtil.decodeBase64(encrypted.payload);

  const messageUint8 = nacl.box.open.after(
    box,
    nonce,
    sharedKey
  );

  if (!messageUint8) {
    throw new Error("Decryption failed");
  }

  return naclUtil.encodeUTF8(messageUint8);
}

Формат защищённого WebSocket-сообщения

Типичная структура пакета:

{
  "type": "message",
  "data": {
    "nonce": "base64...",
    "payload": "base64..."
  }
}

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


Обеспечение уникальности nonce

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

Практика генерации:

  • nacl.randomBytes(24)
  • либо счётчик + случайный seed

Более устойчивый вариант — комбинация:

const nonce = new Uint8Array(24);
nonce.set(nacl.randomBytes(16));
nonce.set(counterBytes, 16);

Потоковое шифрование сообщений

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

Схема:

  • разбиение данных на чанки
  • шифрование каждого чанка отдельно
  • сохранение порядка доставки на уровне приложения
function sendChunked(data, sharedKey, socket) {
  const chunkSize = 1024;

  for (let i = 0; i < data.length; i += chunkSize) {
    const chunk = data.slice(i, i + chunkSize);

    const encrypted = encryptMessage(chunk, sharedKey);

    socket.send(JSON.stringify({
      type: "chunk",
      data: encrypted,
      index: i / chunkSize
    }));
  }
}

Сборка потоковых данных на принимающей стороне

const buffer = [];

function handleChunk(message) {
  const decrypted = decryptMessage(message.data, sharedKey);

  buffer[message.index] = decrypted;
}

function assemble() {
  return buffer.join("");
}

Защита от подмены сообщений

TweetNaCl обеспечивает аутентифицированное шифрование. Это означает:

  • изменение ciphertext делает расшифровку невозможной
  • подмена nonce также приводит к ошибке

Однако дополнительная логика может включать:

  • sequence number
  • контроль целостности потока
  • проверку дубликатов
const state = {
  lastIndex: -1
};

function validateSequence(index) {
  if (index <= state.lastIndex) {
    return false;
  }
  state.lastIndex = index;
  return true;
}

Интеграция с WebSocket событиями

socket.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data);

  if (msg.type === "message") {
    const text = decryptMessage(msg.data, sharedKey);
  }
});

Обновление ключей в процессе сессии

Для повышения безопасности применяется ротация ключей:

  • генерация новой пары ключей
  • повторный обмен публичными ключами
  • пересчёт shared secret
function rotateKeys() {
  const newPair = nacl.box.keyPair();

  socket.send(JSON.stringify({
    type: "rotate",
    publicKey: naclUtil.encodeBase64(newPair.publicKey)
  }));
}

Ограничения модели

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

  • увеличение размера сообщений из-за base64 и nonce
  • необходимость ручного управления состоянием
  • отсутствие встроенной сессии или протокола обмена ключами
  • необходимость синхронизации состояния на обеих сторонах

Структура защищённого канала обмена

  1. WebSocket соединение устанавливается без доверия к содержимому
  2. Клиенты обмениваются публичными ключами
  3. Вычисляется shared secret
  4. Все сообщения проходят через nacl.box.after
  5. Каждое сообщение сопровождается nonce
  6. Приёмник выполняет nacl.box.open.after

Поведение при ошибках дешифрования

Ошибки дешифрования означают:

  • нарушение целостности данных
  • неверный ключ
  • повтор nonce
  • повреждение пакета
const result = nacl.box.open.after(box, nonce, sharedKey);

if (!result) {
  // пакет игнорируется без восстановления данных
}

Практическая модель безопасного обмена сообщениями

Комбинация WebSocket и TweetNaCl.js формирует слой, в котором:

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