Два месяца назад я опубликовал статью про свой pet-проект — E2EE-мессенджер.
Честно говоря, я не ожидал, что она вызовет такой интерес. Прилетели комментарии, замечания, вопросы и довольно полезная критика. Где-то меня поправили по делу. Где-то заставили пересмотреть решения, которые мне самому на тот момент казались нормальными.
И, что самое приятное, часть вещей, которые тогда были только в roadmap, за это время удалось реализовать.
Первая часть:
https://habr.com/ru/articles/1030854/
Проект:
https://github.com/vaazhen/chaos-e2ee-messenger
Вообще, за эти два месяца я понял одну простую вещь.
Написать чат оказалось не так сложно.
Написать E2EE-мессенджер — уже сложнее.
А вот понять, почему Signal делали столько лет, оказалось совсем другой историей.
Сначала кажется: ну есть же документация, есть X3DH, есть Double Ratchet, есть WebCrypto, есть статьи, есть спецификации. Осталось просто аккуратно всё собрать.
Примерно в этот момент где-то вдалеке начинает смеяться инженер из Signal.
Сразу оговорюсь: Chaos — это не замена Signal и не “готовый безопасный мессенджер”, а open-source pet-проект, на котором я разбираюсь, как E2EE-системы устроены изнутри.
Зачем я продолжаю этим заниматься (я безработный)
Изначально я хотел просто разобраться, как работают X3DH и Double Ratchet.
Потом стало интересно сделать не абстрактный пример на 200 строк, а что-то похожее на живую систему:
backend;
frontend;
realtime;
база данных;
доставка сообщений;
несколько устройств;
observability;
desktop-клиент;
деплой;
безопасность;
странные edge cases, которые появляются в самый неподходящий момент.
Постепенно проект перестал быть “чатиком на Spring Boot”.
Он начал превращаться в систему, где любое решение про ключи внезапно влияет на базу данных, WebSocket, UX, preview сообщений, авторизацию, восстановление сессий и даже на то, что можно показывать в списке чатов.
Именно поэтому он до сих пор не надоел.
Как сейчас выглядит проект
Если в первой версии всё выглядело довольно просто, то сейчас проект заметно разросся.

Схема, конечно, сильно упрощённая. В реальности всё чуть более шумно, потому что у любого живого проекта есть свойство постепенно превращаться в маленький дата-центр у тебя на ноутбуке.
Сейчас в проекте есть:
React-фронтенд;
Electron desktop-приложение;
Spring Boot backend;
REST API;
STOMP/WebSocket для realtime;
PostgreSQL для пользователей, устройств, сообщений, envelope'ов, prekeys и вложений;
Redis для rate limits, сессий, online status и refresh-токенов;
Docker Compose;
Prometheus;
Grafana;
Loki;
Caddy;
Kubernetes-манифесты.
Но главный вывод здесь не в количестве технологий.
Главный вывод в том, что E2EE — это не отдельный crypto-service и не одна функция в коде. Это архитектурное свойство всей системы.
Если сервер не должен видеть plaintext, это влияет почти на всё.
И именно в этот момент начинаешь понимать, что E2EE — это не функция encrypt(), а ограничение, которое постепенно прорастает через всю систему.
Сервер должен знать как можно меньше
Одна из вещей, которая сильнее всего поменялась за это время, — моё понимание роли сервера.
В обычном мессенджере сервер часто является источником истины почти обо всём:
текст сообщения;
preview последнего сообщения;
поиск;
история;
статусы;
вложения;
индексация.
В E2EE-мессенджере сервер должен быть намного тупее.
И это комплимент.
Он должен принимать зашифрованные envelope'ы, хранить ciphertext и доставлять его нужным устройствам. Всё остальное — по возможности мимо него.

Упрощённо доставка выглядит так:
Alice шифрует сообщение локально.
Для каждого устройства Bob создаётся отдельный encrypted envelope.
Сервер сохраняет ciphertext.
Сервер доставляет envelope'ы всем устройствам Bob.
Bob расшифровывает сообщение локально.
Read receipt уходит обратно как отдельное событие.
Сервер при этом не видит текст сообщения.
Но важно: это не значит, что сервер “ничего не знает вообще”. Он всё ещё видит метаданные: кто с кем общается, когда были события, сколько примерно данных передано, какие устройства активны.
И это отдельная большая тема.
Когда [encrypted] — это не баг
Один из забавных моментов был с preview последнего сообщения.
На сервере поле content выглядит примерно так:
[encrypted]
Поначалу это немного раздражает.
Открываешь базу, смотришь на сообщение и видишь не текст, а заглушку. Первая мысль: “Ну всё, опять что-то сломалось”.
А потом приходит понимание: нет, дружище, это как раз оно и работает.
Сервер не должен знать, что находится внутри сообщения.
В обычном приложении preview последнего сообщения — это простой SQL-запрос.
В E2EE-мессенджере всё интереснее. Preview приходится хранить локально на клиенте после расшифровки. Сервер может хранить только безопасную заглушку, потому что иначе он снова начинает знать то, чего знать не должен.
В какой-то момент я поймал себя на мысли, что если очень хочется решить проблему на сервере, сначала стоит задать себе вопрос:
А должен ли сервер вообще знать ответ?
Очень часто правильный ответ — нет.
И после этого архитектура становится неудобнее.
Зато честнее.
Самое большое изменение — я добрался до DH Ratchet
В комментариях к первой статье мне справедливо указали, что тогда настоящего Double Ratchet ещё не было.
Была начальная установка сессии через X3DH и симметрическое обновление message keys, но не хватало важной части — DH Ratchet. А без него не получается нормального break-in recovery.
За эти два месяца я добрался до этой части.

Если объяснять очень грубо, Double Ratchet состоит из двух идей.
Первая — симметрическая цепочка ключей.
Для каждого сообщения используется новый message key, а старые ключи постепенно становятся бесполезными.
Вторая — DH Ratchet.
Когда собеседник отвечает новым DH-ключом, стороны пересчитывают root key и получают новые sending/receiving chains.
Самое важное здесь — break-in recovery.
Если злоумышленник каким-то образом получил текущий chain key, это плохо. Но после нового DH-обмена старое состояние перестаёт помогать расшифровывать новые сообщения.
Не магия. Не абсолютная защита от всего. Но очень важное свойство протокола.
Упрощённо шаг DH Ratchet в коде выглядит так:
async function dhRatchetStep(session, remotePublicKey) { // переключаемся на новый публичный DH-ключ собеседника session.remoteDhPublicKey = remotePublicKey; // обновляем receiving chain const receivingSecret = await deriveSharedSecret( session.ownDhKeyPair.privateKey, remotePublicKey ); const receivingKeys = await deriveRootAndChainKey( session.rootKey, receivingSecret ); session.rootKey = receivingKeys.rootKey; session.receivingChainKey = receivingKeys.chainKey; // генерируем новый DH keypair для своей sending chain session.ownDhKeyPair = await generateDhKeyPair(); const sendingSecret = await deriveSharedSecret( session.ownDhKeyPair.privateKey, remotePublicKey ); const sendingKeys = await deriveRootAndChainKey( session.rootKey, sendingSecret ); session.rootKey = sendingKeys.rootKey; session.sendingChainKey = sendingKeys.chainKey; session.sentCount = 0; session.receivedCount = 0; }
На бумаге всё выглядит красиво.
И именно здесь я впервые поймал себя на мысли, что читать спецификацию Signal гораздо проще, чем встроить её в живую систему.
В реальности начинают вылезать неприятные вопросы:
что делать с out-of-order сообщениями;
сколько skipped message keys можно хранить;
что происходит, если одно устройство долго было offline;
как не сломать multi-device;
как восстановиться после переустановки клиента;
где заканчивается удобство и начинается снижение безопасности.
Чем больше я читал спецификацию Signal, тем сильнее начинал уважать людей, которые всё это не просто придумали, а ещё и довели до состояния продукта.
Multi-device ломает простую картинку мира
Когда у пользователя одно устройство, можно притворяться, что всё относительно понятно.
Есть Alice.
Есть Bob.
Есть сессия между ними.
Но потом у Bob появляется телефон, ноутбук и ещё один браузер, который он открыл “на пять минут”, а потом не закрывал полгода.
И простая картинка заканчивается.
Теперь сообщение нужно отправлять не “пользователю Bob”, а каждому устройству Bob отдельно. У каждого устройства свои ключи, prekeys, сессии и envelope'ы.
Сервер при этом превращается в довольно честного курьера:
“Я не знаю, что внутри коробки, но знаю, на какие адреса её отнести”.
И это нормально.
Но multi-device без проверки устройств — это только половина дороги. Технически добавить новое устройство не так сложно. Сложнее доказать пользователю, что это действительно его устройство, а не кто-то тихо подключился к аккаунту.
Именно поэтому в roadmap появились safety numbers и device verification.
Два часа дебага из-за двух стандартов
Ещё одна история, которая хорошо запомнилась.
Клиент подписывает данные через WebCrypto.
Java на сервере должна проверить подпись.
Ключи правильные.
Данные правильные.
Алгоритм правильный.
Всё выглядит так, будто должно работать.
Но подпись не проходит.
После нескольких часов оказалось, что проблема вообще не в “криптографии” в страшном смысле слова.
Проблема была в формате подписи.
WebCrypto возвращает ECDSA-подпись в формате IEEE P1363.
А Java/BouncyCastle в этом месте ожидает ASN.1 DER.
Обе стороны честно делают свою работу.
Просто одна говорит “привет” на одном языке, другая ждёт “hello” на другом, а ты сидишь между ними и думаешь, в какой момент жизнь свернула не туда.

В итоге понадобился конвертер:
byte[] derSignature = convertP1363ToDer(rawSignature); boolean valid = signature.verify(derSignature);
Код небольшой.
Нервов съел прилично.
И это, наверное, один из самых полезных типов багов. Потому что после него ты лучше понимаешь не только свой код, но и границы между браузером, Java и криптографическими библиотеками.
Что неожиданно оказалось не проблемой
Забавно, но Spring Boot, WebSocket, Docker и даже Kubernetes оказались не самой сложной частью.
Да, у каждого из них есть свои приколы. Kubernetes вообще иногда ведёт себя так, будто не ты деплоишь приложение, а он проводит собеседование.
Но настоящие проблемы начались не там.
Настоящие проблемы начались вокруг доверия, ключей и состояния сессий.
Чем дальше развивается проект, тем меньше времени уходит на “просто написать код” и тем больше — на понимание того, какое поведение вообще должно быть правильным.
Было и стало
Если совсем коротко, проект за два месяца прошёл примерно такой путь:

Было | Стало |
|---|---|
X3DH | X3DH + DH Ratchet |
Web-клиент | Web + Electron Desktop |
Single device | Multi-device |
Базовая наблюдаемость | Prometheus + Grafana + Loki |
Docker | Docker + Kubernetes |
Серверная логика чата | Сервер как маршрутизатор encrypted envelope'ов |
Но для меня главное изменение не в этом списке.
Главное изменение — поменялась модель мышления.
В начале проекта я думал примерно так:
Как мне отправить сообщение?
Теперь вопрос звучит иначе:
Какие данные система имеет право знать, чтобы доставить сообщение?
Это совсем другой уровень неприятности.
И, кажется, именно там начинается настоящая инженерия E2EE.
Немного про AI
Да, часть бойлерплейта, тестов и рутинных кусков помогали писать Claude и ChatGPT. Но чем дальше развивался проект, тем больше выяснялось, что вопросы вокруг доверия, восстановления сессий, компрометации устройств, forward secrecy и архитектурных компромиссов всё равно приходится решать самому. В E2EE понимание важнее генерации кода.
Что всё ещё слабое место
Чтобы не создавать лишних иллюзий, вот что я сам пока считаю незавершённым.
Web-доставка frontend-кода. Даже если backend не видит plaintext, web-клиент всё равно загружается с сервера. При определённой модели угроз сервер или инфраструктура доставки могут попытаться подменить клиентский код. Desktop-приложение частично снижает этот риск, но не убирает его полностью.
Safety numbers и device verification. Пока нет удобного механизма, который позволяет пользователям проверить отпечатки ключей и убедиться, что новое устройство действительно принадлежит собеседнику. Без этого multi-device остаётся неполным с точки зрения UX доверия.
Metadata leakage. Сервер не видит текст сообщений, но всё ещё видит часть метаданных: когда были события, какие устройства подключены, кому нужно доставить envelope, какие сессии активны. Это отдельный пласт проблем, который нельзя честно игнорировать.
Security review. Проект не проходил внешний криптографический аудит. А когда речь идёт про E2EE, это важно проговаривать прямо. Сам себе security review — это примерно как самому себе стоматолог: теоретически интересно, практически лучше не надо.
Что дальше
Сейчас в планах:
Safety Numbers;
Device Verification;
Push Notifications;
Android-клиент;
внешний security review;
дальнейшая работа над multi-device;
улучшение восстановления сессий.

За эти два месяца проект стал больше.
Но главный вывод оказался не про количество кода, не про Docker, не про Kubernetes и даже не про Double Ratchet.
Главный вывод в том, что сложность E2EE начинается не там, где появляется AES-GCM или X25519.
Она начинается там, где нужно решить:
кому можно доверять;
какие данные сервер имеет право знать;
что делать с несколькими устройствами;
как жить после компрометации;
где безопасность конфликтует с удобством;
как не сломать всё это красивым UX.
Наверное, именно поэтому большие мессенджеры развиваются годами.
А мне пока просто интересно продолжать копать.
И, кажется, чем глубже копаешь, тем больше начинаешь понимать, почему у Signal до сих пор выходят статьи, спецификации и новые версии протокола. Потому что в таких системах вопросы почему-то никогда не заканчиваются.
И да, спасибо всем, кто критиковал первую статью.
Многие вещи, которые сейчас есть в проекте, появились именно благодаря комментариям.
Так что если увидите криптографический косяк, архитектурную дыру или просто странное решение — обязательно пишите.
В прошлый раз это уже помогло.
