Как я написал E2EE-мессенджер на Spring Boot и WebCrypto — и почему сервер не видит сообщения
Разбираю pet-проект Chaos Messenger: full-stack E2EE-мессенджер на Spring Boot 3 и React, где сообщения шифруются в браузере, а сервер хранит только encrypted envelopes и работает как слепой роутер. Покажу архитектуру, X3DH, symmetric ratchet, WebSocket-доставку, multi-device и ограничения текущей реализации.
Я Java-разработчик и в основном работаю с backend: Spring Boot, базы данных, интеграции, авторизация, WebSocket — всё то, что обычно находится за интерфейсом.
В какой-то момент я поймал себя на мысли: я каждый день пользуюсь мессенджерами, но плохо понимаю, как они устроены внутри. Окей, JWT, WebSocket, PostgreSQL, Redis — это понятно. Но что технически означает фраза "end-to-end encryption"? Как сервер доставляет сообщения, если он не должен их читать? Где живут ключи? Что хранится в базе? Что происходит, если у пользователя два устройства?
Решил разобраться через практику. Написал мессенджер с нуля. Назвал Chaos Messenger.
Сразу честно: криптографическую часть я изучал вместе с Claude и ChatGPT — читал спецификации X3DH и Double Ratchet, разбирал примеры, задавал вопросы, пока не сложилась цельная картина. Frontend тоже делался с активной помощью ChatGPT: я backend-разработчик, React для меня не основная среда. Но архитектура, backend, интеграция WebCrypto, модель конвертов, хранение сообщений и принципиальные решения — мои.
Для меня AI здесь был не заменой понимания, а инструментом — примерно как документация, Stack Overflow и ревью коллег. Без понимания threat model и архитектуры такой проект всё равно не собрать.
В статье расскажу, как работает E2EE изнутри: как устанавливается сессия через X3DH, как каждое сообщение получает отдельный ключ через Symmetric Ratchet, почему сервер хранит только зашифрованные конверты, и какие ошибки я допустил по дороге.
Стек: Spring Boot 3, React 18, WebCrypto API, PostgreSQL, Redis, WebSocket/STOMP, Prometheus, Grafana.
Важная оговорка про web-E2EE
Когда я говорю, что сервер не может прочитать сообщения, я имею в виду backend, базу данных, WebSocket-слой и уже сохранённые ciphertext-конверты. У них нет ключей и plaintext.
Но у web-E2EE есть отдельная проблема: frontend-код тоже приходит с сервера. Теоретически скомпрометированный сервер может отдать изменённый JavaScript, который украдёт ключи или plaintext до шифрования. Это ограничение не конкретно моего проекта, а браузерной модели в целом.
Поэтому корректная формулировка такая: backend не получает ключи и не может расшифровать уже переданные или сохранённые сообщения. Защита от подмены клиентского кода — отдельный слой безопасности: подпись сборок, независимая верификация клиента, desktop/mobile-приложения, reproducible builds.
Почему обычный подход не работает
Большинство "мессенджеров" на GitHub выглядят примерно так:
Сервер знает всё. Видит каждое сообщение. Если БД утекла — утекла вся переписка. Если сервер взломали — читай что хочешь. Если завтра компания решит продать данные — технически ничего не мешает.
E2EE решает это радикально: backend не получает ключи и не хранит plaintext. Сообщение шифруется на устройстве отправителя до отправки в сеть, а расшифровывается только на устройстве получателя.
Это уже не вопрос политики конфиденциальности в стиле "мы обещаем не читать". Это архитектурное ограничение: если у сервера нет ключа, он не может превратить ciphertext обратно в текст.
Звучит как магия. На самом деле — два протокола и немного WebCrypto.
Главная идея: конверты
Представь что Алиса хочет написать Бобу. Вместо того чтобы положить письмо на стол и надеяться что никто не прочитает — она кладёт его в запечатанный конверт. Конверт может открыть только Боб своим ключом. Сервер просто передаёт конверт не заглядывая внутрь.
Именно так это работает в коде. В базе данных у меня это выглядит так:
Когда я впервые увидел
в своей БД вместо текста — стало понятно, что модель наконец работает правильно: сервер создал сообщение, доставил его, сохранил метаданные, но так и не узнал содержимое.
А вот что сервер возвращает при запросе списка чатов через API:
Не
. Не
. Буквально
(DevTools → Network → ответ API с
)
Откуда берутся ключи: X3DH
Главный вопрос: как Алиса и Боб получают общий секрет, если они никогда раньше не общались? И как сделать это так, чтобы сервер только помог передать публичные данные, но сам не смог вычислить итоговый ключ?
Для этого используется X3DH — Extended Triple Diffie-Hellman, протокол из экосистемы Signal. Его задача — установить общий секрет между двумя устройствами, используя долгосрочные и временные ключи.
Что хранится на сервере
Когда пользователь регистрирует устройство, он загружает на сервер пакет публичных ключей:
На сервер уходят только публичные части. Приватные ключи сериализуются и хранятся локально в браузере — и никогда не покидают устройство в сеть.
Здесь важно сказать честно: хранение приватных ключей в
Более строгий вариант — использовать Web Crypto API с
, чтобы приватный ключ жил внутри браузерного crypto runtime и его нельзя было экспортировать в байты. Но у этого подхода есть практическая сложность: ключи нужно переживать между перезагрузками страницы, синхронизировать с IndexedDB, аккуратно восстанавливать состояние устройства и не сломать UX.
В браузерных E2EE-приложениях обычно приходится выбирать между несколькими вариантами:
- Сериализуемые ключи в localStorage или IndexedDB — проще реализовать, но нужно очень серьёзно относиться к XSS и целостности frontend-кода.
- extractable: false + IndexedDB — безопаснее, но сложнее в реализации и восстановлении состояния.
- Нативное secure storage вроде Android Keystore или iOS Secure Enclave — лучший вариант для мобильных клиентов, но он недоступен обычному web-приложению.
В текущей версии Chaos Messenger используется первый вариант. Это осознанный компромисс для pet/open-source проекта и удобного запуска в браузере. Переход на non-extractable ключи и более строгую модель хранения стоит в roadmap.
Ключевой момент: backend всё равно не получает приватные ключи и не может расшифровать сохранённые ciphertext-конверты. Но защита ключей на клиенте — отдельная задача, и её нельзя честно замалчивать.
Установка сессии
Когда Алиса открывает переписку с Бобом впервые, происходит следующее:
В классическом X3DH четвёртая DH-операция с one-time prekey опциональна: она выполняется, если сервер выдал доступный OPK получателя. В моей реализации устройство публикует набор one-time prekeys при регистрации, поэтому первое сообщение обычно использует DH4. Если OPK закончились, сессию всё равно можно установить через остальные DH-компоненты, но это уже менее сильный вариант.
Боб, получив конверт с эфемерным публичным ключом Алисы, повторяет те же операции со своими приватными ключами и получает тот же самый
. Математика симметрична.
Сервер в этот момент видит только публичные ключи и зашифрованный конверт. Он помогает устройствам найти друг друга, но не участвует в вычислении секрета.
Получить
Как шифруется каждое сообщение: Symmetric Ratchet
X3DH даёт нам стартовый
. Но использовать один и тот же ключ для всех сообщений — плохая идея. Если использовать один ключ для всей переписки, компрометация этого ключа сразу открывает весь поток сообщений.
Решение — симметричный ratchet. После каждого сообщения цепочка ключей продвигается вперёд:
Визуально это выглядит так:
используется для шифрования одного сообщения через AES-GCM, после чего уничтожается. Если атакующий компрометирует
— он прочитает только второе сообщение.
В рамках такой симметричной цепочки это даёт forward secrecy назад по цепочке: зная текущий или отдельный
Само шифрование сообщения:
А вот что уходит на сервер — живой пример из DevTools:
Сервер получает
и
. Расшифровать без
Важная оговорка: это ещё не полный Double Ratchet
В этом проекте реализован Symmetric Ratchet — цепочка, где из
для каждого сообщения выводится отдельный
Это защищает прошлые сообщения: если атакующий узнает текущий ключ или отдельный
, он не сможет откатить HMAC назад и получить старые ключи.
Но это не полный Double Ratchet из Signal Protocol.
В полном Double Ratchet есть ещё DH ratchet step: стороны периодически выполняют новый Diffie-Hellman обмен и обновляют root key. Это даёт break-in recovery — возможность восстановить безопасность будущих сообщений после компрометации части состояния.
В моей реализации DH ratchet step пока нет. Если атакующий получит актуальное состояние сессии на устройстве и сможет продолжать его читать, он сможет расшифровывать будущие сообщения до переустановки сессии. Это честное ограничение текущей версии, и оно стоит первым пунктом в roadmap.
Мультиустройство: один пользователь, несколько конвертов
Первый неочевидный момент: в E2EE сообщение адресуется не просто пользователю, а конкретным устройствам пользователя.
Если у Боба два устройства — телефон и ноутбук — нужен отдельный encrypted envelope для каждого устройства. Сервер не может взять один конверт, расшифровать его и "переупаковать" для второго устройства: у него нет ключей и он не знает plaintext.
Значит при отправке сообщения нужно зашифровать его отдельно для каждого устройства каждого участника чата.
Для чата где у каждого по 2 устройства — 4 конверта на одно сообщение. Для группы из 10 человек — потенциально 20 конвертов. Это нормально, это цена безопасности.
Сервер: хранение и доставка конвертов
На сервере сообщение создаётся с контентом
, а конверты сохраняются отдельно:
После сохранения — fanout по WebSocket. Каждое устройство получает свой конверт и только его:
Это важное отличие от обычного WebSocket-чата. В обычном чате сервер рассылает одно и то же событие всем участникам. В E2EE-чате сервер рассылает разные события разным устройствам: payload для каждого устройства содержит свой
Топик
— строго персональный. Устройство А не получает конверт устройства Б. Никакого broadcast — только адресная доставка.
Архитектура целиком
Баг который долго не замечал
В панели чатов показывается превью последнего сообщения. Я реализовал это через
Запускаю — в списке чатов у всех написано
.
Конечно. Сервер же не знает что там написано.
Я полчаса думал как решить это на сервере. Потом дошло: нельзя решить это на сервере — у него нет ключей. Решение только на клиенте.
После того как пользователь открыл чат и сообщения расшифровались — кешируем последнее в памяти:
Это хороший пример того, как E2EE меняет привычное мышление backend-разработчика. В обычном приложении preview — это поле в SQL-запросе. В E2EE-приложении preview — это локальное клиентское состояние, потому что только клиент видел plaintext.
Простое решение. Но чтобы к нему прийти нужно было полностью принять идею что сервер здесь просто не при делах — и перестать пытаться решить задачу на его стороне.
Rate limiting: дыра которую легко не заметить
Эндпоинт
отправляет SMS с кодом. Без защиты любой скрипт может дёргать его тысячи раз — это называется SMS pumping fraud, SMS стоят реальных денег.
Redis у нас уже был для хранения онлайн-статусов. Добавил rate limiting поверх него:
При превышении — HTTP 429 с заголовком
. Клиент знает через сколько секунд можно повторить.
Важный нюанс: в текущей реализации, если Redis недоступен, сервис не блокирует авторизацию полностью. Для pet-проекта это приемлемый компромисс: лучше рискнуть одним лишним SMS, чем положить вход в приложение.
В production я бы сделал строже: fallback in-memory лимит на инстанс, отдельные лимиты по IP и телефону, антифрод-логику и алерты на всплески отправки кодов.
Авторизация WebSocket
Отдельная история — авторизация WebSocket соединений. HTTP-эндпоинты защищены Spring Security автоматически, но WebSocket — другое дело. STOMP-соединение устанавливается один раз, и нужно проверять JWT при каждом подключении.
Отдельно важно не только проверить JWT, но и связать WebSocket-соединение с конкретным устройством. Пользователь может быть один, но устройств у него несколько, а encrypted envelope адресован именно
Поэтому при подключении я проверяю не только токен, но и
: устройство должно быть зарегистрировано и принадлежать текущему пользователю. Иначе легко случайно превратить per-device E2EE-доставку обратно в обычный broadcast по пользователю.
Что получилось — живые скрины
Что реализовано:
- E2EE-модель с per-device encrypted envelopes
- X3DH session setup + Symmetric Ratchet + AES-GCM
- Мультиустройство
- Личные и групповые чаты
- Realtime доставка через WebSocket/STOMP
- Статусы SENT → DELIVERED → READ
- Редактирование и soft delete сообщений
- Online presence, typing indicator
- Фото-вложения
- Поиск пользователей
- Rate limiting на SMS через Redis
- Prometheus метрики + Grafana дашборд
- Swagger UI с JWT авторизацией
- 24 backend-теста на Testcontainers, 12 frontend на Vitest, E2E на Playwright
- GitHub Actions CI
Что ещё не сделано:
- Полный Double Ratchet с DH ratchet step и break-in recovery
- Ротация signed prekey и аккуратное пополнение one-time prekeys
- Более строгая модель хранения приватных ключей на клиенте: non-extractable CryptoKey + IndexedDB
- Защита от подмены frontend-кода: подпись сборок, независимая верификация клиента, reproducible builds
- Android-клиент с Android Keystore
- Реальный SMS-провайдер вместо кода в backend-логах
- Push-уведомления без утечки содержимого сообщений
- Более строгая metadata-модель для групповых чатов
Главный инсайт
E2EE — это архитектурное решение, а не библиотека.
Нельзя взять обычный Spring Boot чат и просто "включить шифрование". Нужно с самого начала проектировать систему так, чтобы backend не был участником доверенной зоны: он не должен получать plaintext, не должен иметь ключи и не должен уметь пересобирать сообщение из данных в базе.
Это меняет почти всё:
- структуру БД — вместо текста появляются encrypted envelopes
- API — сервер отдаёт [encrypted], а не preview сообщения
- WebSocket — доставка идёт не по пользователю, а по конкретному устройству
- мультиустройство — одно сообщение превращается в несколько ciphertext-конвертов
- frontend — становится полноценной криптографической частью системы, а не просто UI
Второй инсайт: мессенджер — это не "чат с WebSocket". В E2EE-модели это система доставки зашифрованных конвертов с адресацией по устройствам. Как только это принимаешь, многие странные на первый взгляд решения становятся логичными.
Репозиторий
Код открыт: github.com/vaazhen/chaos-messenger
В репозитории есть README на русском и английском, диаграммы, скриншоты, security audit, Docker Compose и запуск одной командой.
Проект не претендует на уровень production-криптомессенджера вроде Signal. Это учебный и инженерный open-source прототип, цель которого — показать, как E2EE меняет архитектуру backend, frontend и realtime-доставки.
Если вы делали что-то похожее — особенно интересно сравнить подходы к ротации prekey-ов, хранению non-extractable ключей в браузере и реализации DH ratchet step. Вопросы и критика приветствуются.