javascript

Я реализовал Double Ratchet в React Native мессенджере. Разбор протокола и кода

  • вторник, 12 мая 2026 г. в 00:00:07
https://habr.com/ru/articles/1033830/

Уровень: senior · нужно базовое понимание криптографии (AES, ECDH, KDF) Стек: React Native, Expo SDK 54, WebCrypto API, expo-secure-store, TypeScript Что внутри: разбор протокола Signal Double Ratchet, реализация на ~300 строк, грабли, оговорки про отступления от канона

Зачем мне понадобился Double Ratchet

В прошлой статье про трёхуровневый кэш сообщений я уже упоминал, что делаю мессенджер ONEMIX на React Native. Базовое E2E у меня было простое: ECDH P-256 для обмена ключами при первом контакте, AES-GCM для шифрования каждого сообщения общим секретом. Это работает, но имеет одну проблему: общий секрет один на всю переписку. Если у одной из сторон скомпрометируют приватный ключ — все сообщения за всё время превращаются в открытый текст.

Это называется отсутствием Perfect Forward Secrecy (PFS). И это значит, что человек, к которому в руки попадёт твой телефон через год, может прочитать переписку из прошлого года. WhatsApp, Signal, и серьёзные части Telegram давно используют другую схему — Double Ratchet — которая ключи переизбретает заново на каждом сообщении. Так делают потому, что любой ключ компрометируется в один момент времени, и компрометация не должна давать доступа ни к прошлому, ни к будущему.

Я реализовал Double Ratchet с нуля для ONEMIX. В этой статье разберу:

  • Как устроен протокол на уровне идеи (без матана).

  • Мою реализацию в ~300 строк TypeScript поверх WebCrypto API.

  • Где я отступил от канонического Signal Protocol и почему.

  • Грабли, которые я собрал по дороге.

Сразу оговорка про "production-grade". Я делаю мессенджер один, моя реализация прошла мои тесты, но это не аудит NCC Group и не Trail of Bits. Если вам нужен криптографически безупречный код для критичной инфраструктуры — берите libsignal-protocol-typescript или нативные биндинги. Эта статья — про то, как протокол устроен внутри и как его можно реализовать руками. Полезно понимать, как работает то, чем ты пользуешься. Учиться лучше всего, написав самому, разбив несколько раз и собрав снова.

Часть 1. Идея Double Ratchet за 5 минут

У протокола два "храповика" (отсюда название). Каждый храповик — это механизм, который двигается только вперёд: после щелчка нельзя вернуться. Это значит, что зная состояние сегодня, нельзя восстановить состояние вчерашнее.

Симметричный храповик (Symmetric Ratchet). На каждое сообщение берётся текущий "chain key" (CK), и из него KDF выводит два значения: новый chain key и message key (MK). MK используется один раз для шифрования сообщения и сразу выбрасывается. CK заменяется на новый — старый удаляется. Если злоумышленник захватит CK сейчас, он сможет вывести MK для следующих сообщений, но не для предыдущих, потому что KDF — односторонняя функция.

CK_0 → KDF → (CK_1, MK_0)    # сообщение 0 зашифровано MK_0
CK_1 → KDF → (CK_2, MK_1)    # сообщение 1 зашифровано MK_1
CK_2 → KDF → (CK_3, MK_2)    # сообщение 2 зашифровано MK_2

Это даёт forward secrecy в одну сторону: захват состояния не раскрывает прошлое. Но не защищает от будущей компрометации в обратную сторону — если злоумышленник знает CK_2, он может вычислить CK_3, CK_4 и всё что дальше.

DH-храповик (DH Ratchet). Решает обратную задачу. Каждый раз когда стороны меняются ролями (Алиса написала, Боб отвечает), генерируется новая пара DH-ключей. Из ECDH с новыми ключами получается новый общий секрет, и из него HKDF выводит новый root key (RK) и новый chain key для следующей цепочки. Этот шаг "перезагружает" симметричный храповик с независимой от прошлого случайной величиной. Это называется post-compromise security или break-in recovery: даже если злоумышленник захватил полное состояние клиента, после следующего DH-шага он снова в неведении.

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

И один тонкий момент. Сообщения в реальной сети приходят не по порядку. Если Алиса отправила 5 сообщений, а у Боба пришли #1, #3, #5 (а #2 и #4 застряли в Push-нотификациях), Боб должен уметь расшифровать #3 не зная #2. Это решается через skipped message keys: при получении #3 Боб вычисляет MK для #2 и сохраняет его в MKSKIPPED. Когда #2 наконец приходит, Боб достаёт MK из таблицы и расшифровывает. Размер таблицы ограничен (MAX_SKIP) — иначе атакующий мог бы заставить нас вычислить и хранить миллионы ключей.

Этого хватит чтобы понимать код.

Часть 2. Структура состояния

Состояние Double Ratchet для одного чата выглядит у меня так:

export interface RatchetState {
  // DH пара текущего отправителя
  DHs_pub:  string;   // base64 raw
  DHs_priv: string;   // base64 pkcs8
  // DH публичный ключ собеседника
  DHr:      string | null;
  // Корневой ключ
  RK:       string;   // base64, 32 байта
  // Цепочки отправки/приёма
  CKs:      string | null;  // sending chain key
  CKr:      string | null;  // receiving chain key
  // Счётчики
  Ns:       number;   // sent messages counter
  Nr:       number;   // received messages counter
  PN:       number;   // prev sending chain length
  // Skipped message keys (для out-of-order)
  MKSKIPPED: Record<string, string>; // "pubkey:N" → base64 key
}

Имена переменных взяты прямо из спецификации Signal Double Ratchet. Это не из эстетики — это сильно облегчает чтение спеки рядом с кодом, и любой кто знает протокол узнаёт переменные сразу.

Сохраняю это всё в expo-secure-store, который под капотом использует iOS Keychain и Android Keystore. Это не идеальная криптографическая защита (Keychain не защищает от рутованного устройства), но это лучше чем AsyncStorage, который лежит plain в файловой системе приложения.

Один ratchet-state на чат: onemix_ratchet_${chatId}. Если у пользователя 50 чатов — 50 независимых ratchet-state.

Часть 3. Криптографические примитивы

Все примитивы — через crypto.subtle (WebCrypto API). В Expo SDK 54 он доступен из коробки через react-native-quick-crypto или подобные polyfill'ы. Это нативная реализация, не на JS, и работает быстро даже на бюджетных Android.

Генерация ECDH-пары:

async function generateDHPair(): Promise<{ pub: string; priv: string }> {
  const kp = await crypto.subtle.generateKey(
    { name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey", "deriveBits"]
  );
  const pub  = await crypto.subtle.exportKey("raw",   kp.publicKey);
  const priv = await crypto.subtle.exportKey("pkcs8", kp.privateKey);
  return { pub: bufToBase64(pub), priv: bufToBase64(priv) };
}

Сразу важная оговорка. Канонический Signal Protocol использует Curve25519 (X25519 для ECDH, Ed25519 для подписей). Curve25519 имеет ряд преимуществ перед P-256: проще в реализации без side-channel уязвимостей, заведомо безопасные параметры (для P-256 параметры взяты из NIST и есть теоретические сомнения в их случайности), быстрее на бюджетном железе.

Я использую P-256, потому что WebCrypto API в RN не поддерживает Curve25519 нативно. Реализовать X25519 в чистом JS можно (например, через @noble/curves), но это будет медленнее нативного crypto.subtle и потребует аудита самой библиотеки. Я сознательно выбрал компромисс: использовать нативный P-256 ценой того, что моя реализация формально не Signal Protocol, а Double Ratchet на P-256. Для большинства threat models это нормально. Если ваша threat model включает NSA-level противника и предположение о подсаженных параметрах NIST — переходите на Curve25519 через @noble/curves или нативные биндинги.

ECDH:

async function dh(privB64: string, pubB64: string): Promise<ArrayBuffer> {
  const privKey = await crypto.subtle.importKey(
    "pkcs8", base64ToBuf(privB64),
    { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]
  );
  const pubKey = await crypto.subtle.importKey(
    "raw", base64ToBuf(pubB64),
    { name: "ECDH", namedCurve: "P-256" }, false, []
  );
  return crypto.subtle.deriveBits({ name: "ECDH", public: pubKey }, privKey, 256);
}

HKDF для двух разных KDF — корневого и цепочного:

async function hkdf(input: ArrayBuffer, salt: string, info: string, length = 32): Promise<ArrayBuffer> {
  const key  = await importHKDFKey(input);
  return crypto.subtle.deriveBits(
    { name: "HKDF", hash: "SHA-256", salt: enc.encode(salt), info: enc.encode(info) },
    key, length * 8
  );
}

/** KDF для корневого ключа: возвращает [новый RK, chain key] */
async function kdfRK(rk: ArrayBuffer, dhOut: ArrayBuffer): Promise<[ArrayBuffer, ArrayBuffer]> {
  const combined = await hkdf(dhOut, dec.decode(rk), "onemix-ratchet-rk", 64);
  return [combined.slice(0, 32), combined.slice(32)];
}

/** KDF для цепочки: возвращает [новый CK, message key] */
async function kdfCK(ck: ArrayBuffer): Promise<[ArrayBuffer, ArrayBuffer]> {
  const [ckBytes, mkBytes] = await Promise.all([
    hkdf(ck, "onemix-chain-key", "onemix-ck", 32),
    hkdf(ck, "onemix-msg-key",   "onemix-mk", 32),
  ]);
  return [ckBytes, mkBytes];
}

Тут есть ещё одна тонкость, о которой надо честно сказать. В каноническом Signal Double Ratchet kdfCK использует HMAC с двумя разными константами (0x01 и 0x02 в качестве input), а не два отдельных HKDF-вызова. Это эквивалентная по безопасности конструкция, но более компактная. Я использовал две HKDF-операции, потому что у crypto.subtle нативно есть HKDF, но нет прямого HMAC-based ratchet primitive — а заводить ещё одну реализацию HMAC поверх HKDF мне не хотелось. Семантически результат тот же: два независимых 32-байтных ключа из одного входа.

Аналогично, kdfRK в каноне — это HKDF с двумя выходами (RK || CK) длины 64 байта, разделёнными пополам. Это я сделал в точности по спецификации.

Часть 4. Инициализация

Когда Алиса и Боб впервые начинают переписку, у нас должен появиться общий секрет, который запускает первую цепочку. В каноническом Signal это делается через X3DH — Triple Diffie-Hellman, который комбинирует identity keys, signed prekeys и one-time prekeys для получения shared secret с защитой от replay.

Я не реализовал полный X3DH. У меня более простой обмен: один ECDH между долгосрочными ключами обеих сторон. Это означает что мой первоначальный shared secret уязвим к replay (если кто-то перехватил первое сообщение Алисы и шесть месяцев спустя воспроизвёл его — Боб его примет). На практике это митируется тем, что Боб запоминает chatId и не примет повторную инициализацию. Но это слабее X3DH.

export async function initSender(
  chatId: string,
  bobPublicKeyB64: string,
  sharedSecretB64: string,
): Promise<RatchetState> {
  const DHs = await generateDHPair();
  const dhOut = await dh(DHs.priv, bobPublicKeyB64);
  const [RK, CKs] = await kdfRK(base64ToBuf(sharedSecretB64), dhOut);

  const state: RatchetState = {
    DHs_pub: DHs.pub, DHs_priv: DHs.priv,
    DHr: bobPublicKeyB64,
    RK: bufToBase64(RK), CKs: bufToBase64(CKs), CKr: null,
    Ns: 0, Nr: 0, PN: 0,
    MKSKIPPED: {},
  };
  await saveState(chatId, state);
  return state;
}

Что здесь происходит:

  1. Алиса генерирует новую ephemeral DH-пару (DHs_pub, DHs_priv). Это её первый ratchet-ключ для этой переписки.

  2. Делает ECDH своей приватной частью с публичным ключом Боба (bobPublicKeyB64) — получает dhOut.

  3. Применяет kdfRK к существующему shared secret (вход) и dhOut — получает новый RK и CKs (sending chain key).

У Алисы есть только sending chain (CKs) — она первая пишет. Receiving chain (CKr) пока null. Боб при первом получении сделает зеркальную операцию: возьмёт свой долгосрочный приватный ключ и публичный DHs Алисы, получит тот же dhOut (свойство ECDH: dh(a, B) = dh(b, A)), применит тот же KDF и получит идентичный CKr. Это и есть магия Diffie-Hellman.

Часть 5. Шифрование сообщения

Это самая короткая операция в Double Ratchet:

export async function ratchetEncrypt(chatId: string, plaintext: string): Promise<string> {
  let state = await loadState(chatId);
  if (!state) throw new Error("Ratchet not initialized for " + chatId);

  if (!state.CKs) {
    // Первое сообщение после получения — нужен DH ratchet
    if (!state.DHr) throw new Error("No DHr");
    const dhOut = await dh(state.DHs_priv, state.DHr);
    const [RK, CKs] = await kdfRK(base64ToBuf(state.RK), dhOut);
    state.RK  = bufToBase64(RK);
    state.CKs = bufToBase64(CKs);
  }

  const [newCKs, MK] = await kdfCK(base64ToBuf(state.CKs!));
  const { iv, ct }   = await aesEncrypt(MK, plaintext);

  const msg: RatchetMessage = {
    v: 3, dh: state.DHs_pub,
    pn: state.PN, n: state.Ns,
    iv, ct,
  };

  state.CKs = bufToBase64(newCKs);
  state.Ns += 1;
  await saveState(chatId, state);

  return JSON.stringify(msg);
}

Логика по шагам:

  1. Если у нас нет sending chain key (мы только что получили сообщение и наш CKs сбросился), делаем DH-шаг через свой текущий приватный и публичный ключ собеседника. Получаем новый RK и CKs.

  2. Применяем симметричный храповик: из CKs выводим следующий CKs и message key MK.

  3. Шифруем plaintext через AES-256-GCM ключом MK с случайным 12-байтным IV.

  4. Формируем сообщение: в заголовке наш текущий публичный DHs (чтобы получатель знал, нужен ли DH-шаг), PN (длина предыдущей цепочки — это нужно для skipped keys на стороне получателя), N (номер сообщения в текущей цепочке).

  5. Сдвигаем sending chain key и инкрементируем счётчик отправленных.

MK после использования выбрасывается. Если злоумышленник захватит наш telephone прямо сейчас, у него будет state.CKs (новый, после сдвига) — но не предыдущий, не использованный MK, и не plaintext.

Часть 6. Расшифровка

Здесь самая сложная логика во всём протоколе.

export async function ratchetDecrypt(chatId: string, encryptedJson: string): Promise<string> {
  let state = await loadState(chatId);
  if (!state) throw new Error("Ratchet not initialized for " + chatId);

  const msg: RatchetMessage = JSON.parse(encryptedJson);
  if (msg.v !== 3) throw new Error("Unknown ratchet version");

  // Проверяем skipped keys
  const skipKey = `${msg.dh}:${msg.n}`;
  if (state.MKSKIPPED[skipKey]) {
    const MK = base64ToBuf(state.MKSKIPPED[skipKey]);
    delete state.MKSKIPPED[skipKey];
    await saveState(chatId, state);
    return aesDecrypt(MK, msg.iv, msg.ct);
  }

  // DH Ratchet шаг если новый DH ключ
  if (msg.dh !== state.DHr) {
    state = await skipMessageKeys(state, msg.pn);
    state = await dhRatchetStep(state, msg.dh);
  }

  // Пропускаем сообщения если есть out-of-order
  state = await skipMessageKeys(state, msg.n);

  // Получаем message key
  const [newCKr, MK] = await kdfCK(base64ToBuf(state.CKr!));
  state.CKr = bufToBase64(newCKr);
  state.Nr += 1;
  await saveState(chatId, state);

  return aesDecrypt(MK, msg.iv, msg.ct);
}

Алгоритм:

  1. Сначала проверяем — может, мы уже когда-то вычислили MK для этого (dh, n) пары и положили в MKSKIPPED. Если да — достаём, удаляем оттуда, расшифровываем.

  2. Если нет — смотрим заголовок msg.dh. Если он отличается от нашего сохранённого state.DHr — собеседник переключился на новый ratchet-ключ. Нужен DH-шаг.

  3. Перед DH-шагом важно: сначала сохранить skipped keys из старой receiving chain до msg.pn (это длина предыдущей цепочки, отправитель её знает и кладёт в заголовок). Иначе если кто-то отправил сообщение в старой цепочке, а оно догонит нас уже после DH-шага — мы не сможем его расшифровать.

  4. Делаем DH-шаг: обновляем CKr, генерируем новую DHs пару, обновляем CKs.

  5. Пропускаем сообщения в новой цепочке до msg.n, сохраняя их MK в MKSKIPPED для будущего использования.

  6. Симметричный храповик: из CKr выводим следующий CKr и MK.

  7. Расшифровываем.

Skipped message keys:

async function skipMessageKeys(state: RatchetState, until: number): Promise<RatchetState> {
  if (!state.CKr) return state;
  if (until - state.Nr > MAX_SKIP) throw new Error("Too many skipped messages");
  while (state.Nr < until) {
    const [newCKr, MK] = await kdfCK(base64ToBuf(state.CKr));
    state.MKSKIPPED[`${state.DHr}:${state.Nr}`] = bufToBase64(MK);
    state.CKr = bufToBase64(newCKr);
    state.Nr += 1;
  }
  return state;
}

MAX_SKIP = 100 — защита от DoS: атакующий мог бы прислать сообщение с N=1000000 и заставить нас сделать миллион KDF-операций. С лимитом 100 это просто отвергается.

Тут есть subtle bug, которого я долго не видел, и поправил в одной из итераций. Если ratchet ещё ни разу не делал DH-шаг (initial state у получателя), то state.DHr уже установлен (это публичный ключ Алисы при init), но первое сообщение от Алисы ещё не приходило. В этом случае skipMessageKeys должен молча выйти, а не пытаться сравнить msg.dh с DHr и сделать "ложный" DH-шаг. Я добавил if (!state.CKr) return state; в начало — это покрывает initial state, когда receiving chain ещё не инициализирован.

Часть 7. DH-шаг — самое интересное

async function dhRatchetStep(state: RatchetState, header_dh: string): Promise<RatchetState> {
  state.PN = state.Ns;
  state.Ns = 0;
  state.Nr = 0;
  state.DHr = header_dh;

  const dhOut1 = await dh(state.DHs_priv, state.DHr);
  const [RK1, CKr] = await kdfRK(base64ToBuf(state.RK), dhOut1);

  const newDH = await generateDHPair();
  const dhOut2 = await dh(newDH.priv, state.DHr);
  const [RK2, CKs] = await kdfRK(RK1, dhOut2);

  return {
    ...state,
    DHs_pub: newDH.pub, DHs_priv: newDH.priv,
    RK: bufToBase64(RK2),
    CKs: bufToBase64(CKs),
    CKr: bufToBase64(CKr),
  };
}

Два DH-шага подряд, не один. Это деталь, которую я не сразу понял по спецификации.

  1. Первый ECDH: наш текущий приватный (DHs_priv, который мы использовали для предыдущей цепочки) с новым публичным ключом собеседника (header_dh). Этот ECDH даёт RK1 и CKr — receiving chain в новой цепочке.

  2. Второй ECDH: генерируем новую DH-пару (newDH) и делаем ECDH её приватной частью с тем же публичным ключом собеседника. Это даёт RK2 (финальный новый RK) и CKs — sending chain в следующей цепочке.

Зачем два шага? Потому что после получения сообщения от собеседника мы должны быть готовы и дешифровать его (для этого нужен CKr), и сразу ответить со свежим ratchet-ключом (для этого нужен CKs из НОВОЙ DH-пары). Один шаг даёт только одно — поэтому их два.

Счётчики сбрасываются в ноль (Ns, Nr). Длина предыдущей цепочки сохраняется в PN — это пойдёт в заголовок следующего отправленного сообщения, чтобы получатель знал, сколько ключей пропустить.

Часть 8. Грабли

Грабля 1: Web Crypto requires raw buffers, не views.

base64ToBuf у меня возвращает ArrayBuffer, но если бы он возвращал Uint8Array (или view), crypto.subtle на iOS принимает, а на Android отказывается. Symptom: "InvalidAccessError: Key usage not allowed" или просто пустой результат. Поправилось приведением всё к ArrayBuffer в одном месте.

Грабля 2: state.CKr может быть null при первом получении.

Когда Алиса инициирует чат, у неё установлены DHr и CKs, но CKr = null. Когда первое сообщение приходит не от Алисы, а первый ratchet-step делает Боб — на стороне Алисы при получении ответа нужно сделать DH-шаг, и CKr появится впервые. Я долго ловил баг "Cannot read property of null" в первом DH-шаге у инициатора. Решилось аккуратной проверкой в skipMessageKeys (показано выше).

Грабля 3: state-race на параллельной отправке.

Если пользователь быстро отправит два сообщения подряд, ratchetEncrypt может запуститься дважды параллельно. Обе копии прочитают одно и то же state.CKs, обе сдвинут счётчик на 1, но в storage запишется только последнее. В итоге одно из двух сообщений будет зашифровано тем же ключом что и другое — катастрофа для security протокола. Решение — простая mutex-очередь на уровне chatId:

const ratchetMutex = new Map<string, Promise<void>>();
async function withRatchetLock<T>(chatId: string, fn: () => Promise<T>): Promise<T> {
  const prev = ratchetMutex.get(chatId) ?? Promise.resolve();
  let release: () => void;
  const next = new Promise<void>(r => { release = r; });
  ratchetMutex.set(chatId, prev.then(() => next));
  await prev;
  try { return await fn(); }
  finally { release!(); }
}

Все вызовы ratchetEncrypt и ratchetDecrypt обёрнуты в withRatchetLock(chatId, () => ...). Это сериализует операции над одним ratchet-state. Цена — некоторая задержка при параллельной отправке (но пользователь физически не может отправить два сообщения в одну миллисекунду).

Грабля 4: secure-store async на iOS.

expo-secure-store на iOS делает блокирующий вызов к Keychain, что может занимать 5-50мс в зависимости от устройства. Если у вас 50 чатов и при старте приложения вы пытаетесь загрузить все ratchet-state параллельно — iOS Keychain выстраивается в очередь и приложение зависает на пару секунд. Решение: загружать ratchet-state лениво, только когда чат фактически открывается или приходит сообщение.

Грабля 5: PKCS#8 не даёт raw public key обратно.

В функции importEncryptedPrivateKey есть честное замечание в коде:

// Из pkcs8 нельзя напрямую получить raw public. Сохраняем то что есть.

Когда пользователь восстанавливает приватный ключ из бэкапа, технически из pkcs8 формата нельзя через crypto.subtle экспортировать обратно raw публичный ключ. Public key нужно отдельно либо хранить в бэкапе, либо передеривировать через нативные библиотеки. У меня сейчас публичный ключ сохраняется отдельно при бэкапе. Не идеально архитектурно — но работает.

Часть 9. Что осталось за кадром

Чтобы быть честным, перечислю что у меня не реализовано и что было бы у настоящего Signal:

  • X3DH для первичного key agreement. У меня более простой ECDH между долгосрочными ключами, замечание выше.

  • Одноразовые prekeys (one-time prekeys). У Signal сервер хранит пачку одноразовых ключей каждого пользователя; первое сообщение к offline-пользователю шифруется одним из них и сервер его выдаёт. У меня этого нет, что незначительно ослабляет защиту offline-перехвата.

  • Подписи долгосрочных ключей. В Signal identity keys подписывают prekey bundle. У меня пока публичные ключи раздаются через сервер без подписей — это значит, что компрометированный сервер теоретически может выдать атакующему фальшивые ключи и провести MITM. Митируется через Safety Numbers (об этом будет в следующей статье), но это уже offline-проверка, не криптографическая.

  • Sealed Sender в каноническом виде Signal. У меня есть конструкция для скрытия отправителя от сервера, но это не полный Signal Sealed Sender со sender certificates и сертификатным revocation. Тоже отдельная история.

Я понимаю, что мог бы это всё доделать, и в каком-то будущем доделаю. Но в state как сейчас Double Ratchet работает и даёт корректные свойства forward secrecy и post-compromise security при условии, что начальный обмен ключами не скомпрометирован и пользователи верифицируют Safety Numbers.

Итоги

Double Ratchet — один из тех протоколов, которые на спецификации выглядят страшно (там действительно много шагов), а в коде укладываются в ~300 строк, из которых половина — обёртка над crypto.subtle. Главные сложности были не в самом протоколе, а в окружении: WebCrypto-quirks, race conditions при отправке, lazy-загрузка состояния, обработка edge cases типа initial state у получателя.

Если планируете делать E2E в своём проекте — мой совет: не выдумывайте свой протокол, реализуйте Double Ratchet по спецификации. Спецификация Signal короткая и понятная, её можно прочитать за час. Большая часть проблем решаются именно следованием канону, а не "интуитивными улучшениями".

И сразу подумайте про X3DH и идентичность долгосрочных ключей. Сам ratchet защищает переписку, но без верификации identity keys (Safety Numbers или эквивалент) у вас остаётся MITM-вектор на этапе первого контакта. Это второй большой кусок, который я сделаю предметом следующей статьи.


В первой статье я разбирал трёхуровневый кэш сообщений — она оказалась интересной 8 тысячам читателей, и это меня очень мотивирует продолжать.

Если есть конкретные вопросы по коду, по протоколу или по реализации — пишите в комменты, на следующую статью пойдёт самое интересное. И если найдёте баг — пишите тоже, я его исправлю и добавлю в "грабли" с указанием автора находки.