惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

N
News and Events Feed by Topic
S
Security @ Cisco Blogs
S
Secure Thoughts
Attack and Defense Labs
Attack and Defense Labs
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Hacker News - Newest:
Hacker News - Newest: "LLM"
Recent Commits to openclaw:main
Recent Commits to openclaw:main
H
Hacker News: Front Page
博客园 - 叶小钗
H
Heimdal Security Blog
Microsoft Security Blog
Microsoft Security Blog
Forbes - Security
Forbes - Security
AI
AI
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
T
Troy Hunt's Blog
罗磊的独立博客
Application and Cybersecurity Blog
Application and Cybersecurity Blog
爱范儿
爱范儿
GbyAI
GbyAI
The Last Watchdog
The Last Watchdog
TaoSecurity Blog
TaoSecurity Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
D
DataBreaches.Net
Recent Announcements
Recent Announcements
Schneier on Security
Schneier on Security
C
Cisco Blogs
美团技术团队
D
Docker
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
WordPress大学
WordPress大学
月光博客
月光博客
雷峰网
雷峰网
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
H
Hackread – Cybersecurity News, Data Breaches, AI and More
A
Arctic Wolf
B
Blog RSS Feed
Cisco Talos Blog
Cisco Talos Blog
C
Cybersecurity and Infrastructure Security Agency CISA
V
Vulnerabilities – Threatpost
V2EX - 技术
V2EX - 技术
Y
Y Combinator Blog
N
News and Events Feed by Topic
www.infosecurity-magazine.com
www.infosecurity-magazine.com
W
WeLiveSecurity
Security Archives - TechRepublic
Security Archives - TechRepublic
G
GRAHAM CLULEY
Jina AI
Jina AI
Hugging Face - Blog
Hugging Face - Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Я реализовал Double Ratchet в React Native мессенджере. Разбор протокола и кода
Виктор · 2026-05-12 · via Все публикации подряд на Хабре

Сложный

16 мин

9.2K

Уровень: 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 с двумя разными константами (0×01 и 0×02 в качестве 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 тысячам читателей, и это меня очень мотивирует продолжать.

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