Сигнальный протокол: упрощённая реализация Double Ratchet

Double Ratchet строится вокруг двух независимых механизмов обновления ключей: симметрического ратчета (symmetric-key ratchet) и диффи-хеллман ратчета (Diffie–Hellman ratchet). В упрощённой реализации на базе TweetNaCl.js основной акцент смещается на управление цепочками ключей и периодическое обновление общего секрета через ECDH на Curve25519.


TweetNaCl.js предоставляет минимальный набор операций, достаточный для построения Double Ratchet:

  • nacl.box.keyPair() — генерация пары ключей Curve25519
  • nacl.scalarMult() — ECDH (обмен Диффи–Хеллмана)
  • nacl.secretbox() — симметричное шифрование (XSalsa20-Poly1305)
  • nacl.hash() — SHA-512
  • nacl.randomBytes() — генерация случайных значений

Ключевое ограничение: отсутствует встроенный HKDF, поэтому его необходимо реализовать вручную через HMAC на базе SHA-512.


Структура состояния Double Ratchet

Каждая сторона диалога хранит минимальный набор состояний:

  • rootKey — корневой ключ (основа для вывода новых ключей)
  • chainKeySend — цепочка ключей отправки
  • chainKeyRecv — цепочка ключей получения
  • DHs — локовая DH-пара (ephemeral key pair)
  • DHr — публичный ключ удалённой стороны
  • messageIndexSend / messageIndexRecv — счётчики сообщений

Вывод ключей: упрощённый KDF

Вместо полноценного HKDF используется конструкция на HMAC:

function hmacSha512(key, data) {
  return nacl.hash(concat(key, data));
}

Упрощённое разделение ключей:

  • rootKey, chainKey = KDF_RK(rootKey, DH_output)
  • messageKey, chainKey = KDF_CK(chainKey)

Где:

KDF_RK = HMAC(rootKey, DH_output || 0x01)
KDF_CK = HMAC(chainKey, 0x02)
KDF_MK = HMAC(chainKey, 0x03)

Симметрический ратчет (Chain Ratchet)

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

  1. Из chainKeySend выводится messageKey
  2. chainKeySend обновляется
  3. Сообщение шифруется через nacl.secretbox

Псевдокод:

function nextSendingKey(state) {
  const mk = hmac(state.chainKeySend, new Uint8Array([3]));
  state.chainKeySend = hmac(state.chainKeySend, new Uint8Array([2]));
  return mk;
}

Шифрование:

function encrypt(state, plaintext) {
  const key = nextSendingKey(state);
  const nonce = nacl.randomBytes(24);
  const ciphertext = nacl.secretbox(plaintext, nonce, key);
  return { nonce, ciphertext };
}

Диффи-хеллман ратчет (DH Ratchet)

При получении нового публичного ключа собеседника происходит полный пересчёт цепочек.

Шаги обновления:

  1. Генерируется новая DH-пара DHs
  2. Вычисляется общий секрет:
DH_out = scalarMult(DHs.secretKey, DHr.publicKey)
  1. Обновляется rootKey и создаётся новая цепочка:
rootKey, chainKeyRecv = KDF_RK(rootKey, DH_out)
  1. Обнуляется индекс сообщений

Инициализация сессии

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

  • Identity key pair
  • Prekey (опционально)

Упрощённая схема:

function initializeSession(theirPublicKey, ourKeyPair, sharedSecret) {
  const DH = nacl.scalarMult(ourKeyPair.secretKey, theirPublicKey);

  const rootKey = hmac(sharedSecret, DH);
  const chainKeySend = hmac(rootKey, new Uint8Array([1]));

  return {
    rootKey,
    chainKeySend,
    chainKeyRecv: null,
    DHs: ourKeyPair,
    DHr: theirPublicKey
  };
}

Обработка входящего сообщения

При получении сообщения выполняется:

  1. Проверка наличия нового DH-ключа
  2. Если ключ изменился — запуск DH ratchet
  3. Вычисление messageKey
  4. Дешифрование через nacl.secretbox.open
function decrypt(state, packet) {
  if (packet.DH !== state.DHr) {
    dhRatchet(state, packet.DH);
  }

  const mk = hmac(state.chainKeyRecv, new Uint8Array([3]));
  state.chainKeyRecv = hmac(state.chainKeyRecv, new Uint8Array([2]));

  return nacl.secretbox.open(packet.ciphertext, packet.nonce, mk);
}

DH ratchet обновление состояния

function dhRatchet(state, newDHr) {
  state.DHr = newDHr;

  state.DHs = nacl.box.keyPair();

  const dhOut = nacl.scalarMult(state.DHs.secretKey, state.DHr);

  const derived = kdfRoot(state.rootKey, dhOut);

  state.rootKey = derived.rootKey;
  state.chainKeyRecv = derived.chainKey;
}

Управление пропущенными сообщениями

Упрощённая модель предполагает ограниченное хранение промежуточных messageKey, если сообщения приходят не по порядку:

  • буферизация ключей
  • ограничение глубины (например, 10–20 сообщений)
  • удаление старых ключей после использования

Особенности реализации на TweetNaCl.js

1. Отсутствие встроенного HKDF

Необходимо вручную строить derivation chain через HMAC.

2. XSalsa20 вместо ChaCha20

Signal использует ChaCha20-Poly1305, но TweetNaCl применяет XSalsa20-Poly1305.

3. Нет AEAD API уровня протокола

Метаданные (например DH ключи) приходится сериализовать вручную.


Минимальная структура сообщения

{
  DH: Uint8Array(32),
  nonce: Uint8Array(24),
  ciphertext: Uint8Array
}

Сериализация ключей

Поскольку Curve25519 ключи — это 32 байта, их обычно передают напрямую или через Base64:

function encodeKey(key) {
  return Buffer.from(key).toString('base64');
}

function decodeKey(str) {
  return Uint8Array.from(Buffer.from(str, 'base64'));
}

Криптографическая связность состояний

Double Ratchet опирается на связку:

  • rootKey → управляет эволюцией всей сессии
  • chainKey → линейная цепочка сообщений
  • messageKey → одноразовый ключ шифрования

Каждое сообщение разрушает предыдущее состояние ключа, обеспечивая forward secrecy и post-compromise security.


Упрощённая модель потока данных

DH exchange → rootKey
     ↓
chainKey (send/recv)
     ↓
messageKey
     ↓
secretbox encryption

Ограничения упрощённой реализации

  • отсутствует полноценная защита от replay на уровне протокола
  • нет полноценного prekey bundle механизма
  • упрощённое управление out-of-order сообщениями
  • ручная сериализация всех структур
  • слабая масштабируемость при большом количестве сессий

Типовая архитектура состояния в JavaScript

class RatchetSession {
  constructor(state) {
    this.rootKey = state.rootKey;
    this.chainKeySend = state.chainKeySend;
    this.chainKeyRecv = state.chainKeyRecv;
    this.DHs = state.DHs;
    this.DHr = state.DHr;
  }

  encrypt(plaintext) {}
  decrypt(packet) {}
  dhRatchet(newDH) {}
}

Роль каждого ратчета в безопасности

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