Аутентификация сообщений при передаче через WebSocket

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

Stanford JavaScript Crypto Library (SJCL) предоставляет набор примитивов для построения таких механизмов: HMAC, SHA-хэши, симметричное шифрование и генерацию случайных чисел. На практике именно HMAC на основе SHA-256 чаще всего используется для аутентификации сообщений в WebSocket-потоке.


WebSocket обеспечивает постоянное двустороннее соединение между клиентом и сервером, но сам по себе не гарантирует:

  • подлинность отправителя сообщения
  • неизменность данных в пути
  • защиту от повторной отправки (replay attack)

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


Базовая модель аутентификации сообщений

На прикладном уровне каждое сообщение дополняется криптографическим кодом целостности (MAC — Message Authentication Code). В SJCL для этого применяется HMAC:

(K, m) = H( (K opad) H((K ipad) m) )

где:

  • K — общий секретный ключ
  • m — сообщение
  • H — хэш-функция (обычно SHA-256)
  • ipad/opad — фиксированные padding-константы

Клиент и сервер заранее разделяют секретный ключ, который никогда не передаётся по сети.


Реализация HMAC в SJCL

SJCL предоставляет простой интерфейс для вычисления HMAC через SHA-256:

const sjcl = require('sjcl');

const secretKey = "shared-secret-key";

// вычисление HMAC для сообщения
function signMessage(message) {
    const hmac = new sjcl.misc.hmac(sjcl.codec.utf8String.toBits(secretKey), sjcl.hash.sha256);
    const hash = hmac.encrypt(message);
    return sjcl.codec.base64.fromBits(hash);
}

Функция signMessage возвращает подпись, которая затем отправляется вместе с сообщением.


Формат сообщения в WebSocket

Для обеспечения проверяемости структура сообщения обычно расширяется:

{
  "payload": {
    "action": "updateBalance",
    "amount": 150
  },
  "timestamp": 1710000000,
  "signature": "base64-hmac-value"
}

Важно, что подпись вычисляется не только от payload, но и от временной метки:

function buildSignedMessage(payload) {
    const message = JSON.stringify(payload);
    const timestamp = Date.now();

    const dataToSign = message + ":" + timestamp;
    const signature = signMessage(dataToSign);

    return {
        payload,
        timestamp,
        signature
    };
}

Проверка подписи на сервере

Сервер выполняет обратную операцию: пересчитывает HMAC и сравнивает значения.

function verifyMessage(msg) {
    const message = JSON.stringify(msg.payload);
    const dataToSign = message + ":" + msg.timestamp;

    const hmac = new sjcl.misc.hmac(sjcl.codec.utf8String.toBits(secretKey), sjcl.hash.sha256);
    const expected = sjcl.codec.base64.fromBits(hmac.encrypt(dataToSign));

    return expected === msg.signature;
}

Критически важно использовать сравнение с защитой от timing attacks в реальных системах, хотя SJCL напрямую этого не гарантирует.


Защита от повторной отправки (Replay Attack)

Одна из ключевых проблем WebSocket-аутентификации — повторное использование валидной подписи.

Решение строится на двух элементах:

  • timestamp
  • nonce (одноразовый идентификатор)

Добавление nonce

function buildSignedMessage(payload) {
    const message = JSON.stringify(payload);
    const timestamp = Date.now();
    const nonce = sjcl.random.randomWords(4, 0).join("");

    const dataToSign = message + ":" + timestamp + ":" + nonce;
    const signature = signMessage(dataToSign);

    return {
        payload,
        timestamp,
        nonce,
        signature
    };
}

На сервере nonce сохраняется в кеше использованных значений. Повторное появление приводит к отклонению сообщения.


Использование SJCL для генерации случайных значений

SJCL включает криптографически стойкий генератор случайных чисел:

const nonce = sjcl.random.randomWords(4, 0);

Он зависит от энтропии браузера или Node.js окружения. При недостаточной энтропии система может быть уязвима, поэтому важно инициализировать генератор событийными источниками (mousemove, keyboard, timing events).


Секретный ключ и его хранение

Безопасность всей схемы полностью определяется качеством ключа:

  • ключ должен быть не предсказуем
  • запрещено хранение в открытом виде на клиенте
  • ключ на клиенте допустим только в ограниченных доверенных средах (например, embedded устройства)

SJCL позволяет формировать ключи через:

const key = sjcl.codec.base64.fromBits(sjcl.random.randomWords(8, 0));

Ошибки проектирования аутентификации WebSocket

Использование только TLS

TLS защищает канал, но не защищает от логических атак внутри сессии. Подменённое сообщение в рамках валидного соединения остаётся проблемой.

Подпись только payload без метаданных

Отсутствие timestamp и nonce позволяет атакующему:

  • повторять старые команды
  • воспроизводить транзакции
  • нарушать порядок операций

Использование слабых хэшей

SHA-1 или MD5 в HMAC приводят к криптографической деградации системы. SJCL поддерживает SHA-256 как базовый безопасный вариант.


Потоковая схема проверки сообщений

Типичный цикл обработки выглядит следующим образом:

  1. клиент формирует payload
  2. добавляет timestamp и nonce
  3. вычисляет HMAC через SJCL
  4. отправляет сообщение через WebSocket
  5. сервер извлекает данные
  6. пересчитывает HMAC
  7. проверяет уникальность nonce
  8. принимает или отклоняет сообщение

Дополнительное усиление модели безопасности

Привязка к сессии

Включение sessionId в подписываемые данные предотвращает перенос подписи между пользователями:

const dataToSign = sessionId + ":" + message + ":" + timestamp + ":" + nonce;

Ограничение времени жизни сообщения

Сервер отклоняет сообщения старше фиксированного интервала (например, 30 секунд), снижая окно атаки.


Использование симметричного шифрования SJCL

Аутентификация часто комбинируется с шифрованием:

const encrypted = sjcl.encrypt(secretKey, JSON.stringify(payload));

Однако важно понимать, что:

  • шифрование без MAC не защищает от подмены
  • MAC без шифрования не скрывает данные
  • комбинированные схемы должны учитывать порядок применения (encrypt-then-MAC)

Практическая архитектура безопасного WebSocket слоя

В устойчивой реализации SJCL применяется как криптографический слой поверх транспортного:

  • WebSocket отвечает за доставку
  • SJCL отвечает за целостность и подлинность
  • прикладной протокол определяет структуру сообщений

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