- PVSM.RU - https://www.pvsm.ru -
Привет.
Я 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.
Когда я говорю, что сервер не может прочитать сообщения, я имею в виду backend, базу данных, WebSocket-слой и уже сохранённые ciphertext-конверты. У них нет ключей и plaintext.
Но у web-E2EE есть отдельная проблема: frontend-код тоже приходит с сервера. Теоретически скомпрометированный сервер может отдать изменённый JavaScript, который украдёт ключи или plaintext до шифрования. Это ограничение не конкретно моего проекта, а браузерной модели в целом.
Поэтому корректная формулировка такая: backend не получает ключи и не может расшифровать уже переданные или сохранённые сообщения. Защита от подмены клиентского кода — отдельный слой безопасности: подпись сборок, независимая верификация клиента, desktop/mobile-приложения, reproducible builds.

Большинство "мессенджеров" на GitHub выглядят примерно так:
message.setContent(request.getText());
messageRepository.save(message);
Сервер знает всё. Видит каждое сообщение. Если БД утекла — утекла вся переписка. Если сервер взломали — читай что хочешь. Если завтра компания решит продать данные — технически ничего не мешает.
E2EE решает это радикально: backend не получает ключи и не хранит plaintext. Сообщение шифруется на устройстве отправителя до отправки в сеть, а расшифровывается только на устройстве получателя.
Это уже не вопрос политики конфиденциальности в стиле “мы обещаем не читать”. Это архитектурное ограничение: если у сервера нет ключа, он не может превратить ciphertext обратно в текст.
Звучит как магия. На самом деле — два протокола и немного WebCrypto.
Представь что Алиса хочет написать Бобу. Вместо того чтобы положить письмо на стол и надеяться что никто не прочитает — она кладёт его в запечатанный конверт. Конверт может открыть только Боб своим ключом. Сервер просто передаёт конверт не заглядывая внутрь.
Именно так это работает в коде. В базе данных у меня это выглядит так:
messages.content = '[encrypted]' -- сервер не знает что внутри
message_envelopes.ciphertext = 'qzgHSg7zbwU6h8j8...' -- зашифровано AES-GCM
Когда я впервые увидел [encrypted] в своей БД вместо текста — стало понятно, что модель наконец работает правильно: сервер создал сообщение, доставил его, сохранил метаданные, но так и не узнал содержимое.
А вот что сервер возвращает при запросе списка чатов через API:
{
"chatId": 32,
"lastMessage": "[encrypted]",
"lastMessageAt": "2026-04-28T22:27:35.537016"
}
Не ***. Не [скрыто]. Буквально [encrypted] — потому что у сервера нет другого значения для возврата. Позже расскажу какой баг из этого вытек.

Главный вопрос: как Алиса и Боб получают общий секрет, если они никогда раньше не общались? И как сделать это так, чтобы сервер только помог передать публичные данные, но сам не смог вычислить итоговый ключ?
Для этого используется X3DH — Extended Triple Diffie-Hellman, протокол из экосистемы Signal. Его задача — установить общий секрет между двумя устройствами, используя долгосрочные и временные ключи.
Когда пользователь регистрирует устройство, он загружает на сервер пакет публичных ключей:
// crypto-engine.js — генерация ключей при регистрации устройства
async function buildNewDeviceBundle() {
const identity = await generateX25519KeyPair(); // долгосрочный ключ устройства
const signedPreKey = await generateX25519KeyPair(); // должен ротироваться периодически
const oneTimePreKeys = [];
for (let i = 0; i < 50; i++) {
const kp = await generateX25519KeyPair();
oneTimePreKeys.push({
preKeyId: 1000 + i,
publicKey: await exportRawPublicKey(kp.publicKey),
privateKeyPkcs8: await exportPkcs8PrivateKey(kp.privateKey)
});
}
// ...
}
На сервер уходят только публичные части. Приватные ключи сериализуются и хранятся локально в браузере — и никогда не покидают устройство в сеть.
Здесь важно сказать честно: хранение приватных ключей в localStorage — это компромисс, а не идеальная криптографическая модель.
localStorage доступен JavaScript-коду страницы. Если в приложении появится XSS-уязвимость или если пользователь получит подменённый frontend-код, приватные ключи можно украсть. Это не ломает X3DH или AES-GCM, но ломает клиентскую среду, в которой эти алгоритмы выполняются.
Более строгий вариант — использовать Web Crypto API с extractable: false, чтобы приватный ключ жил внутри браузерного 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-конверты. Но защита ключей на клиенте — отдельная задача, и её нельзя честно замалчивать.
Когда Алиса открывает переписку с Бобом впервые, происходит следующее:
// crypto-engine.js — X3DH со стороны инициатора
async function createInitiatorSessionWrapped(localBundle, targetDevice) {
const identityPrivate = await importPkcs8PrivateKey(localBundle.identity.privateKeyPkcs8);
const ephemeral = await generateX25519KeyPair(); // одноразовый ключ только для этой сессии
const remoteIdentityPub = await importRawPublicKey(targetDevice.identityPublicKey);
const remoteSignedPreKeyPub = await importRawPublicKey(targetDevice.signedPreKey.publicKey);
// X3DH использует несколько DH-операций.
// DH4 выполняется, если у получателя есть one-time prekey.
const dh1 = await derive32(identityPrivate, remoteSignedPreKeyPub); // IK_alice · SPK_bob
const dh2 = await derive32(ephemeral.privateKey, remoteIdentityPub); // EK_alice · IK_bob
const dh3 = await derive32(ephemeral.privateKey, remoteSignedPreKeyPub); // EK_alice · SPK_bob
const parts = [dh1, dh2, dh3];
if (remoteOneTimePub) {
const dh4 = await derive32(ephemeral.privateKey, remoteOneTimePub); // EK_alice · OPK_bob
parts.push(dh4);
}
const combined = concat(...parts);
// Из combined через HKDF выводим rootKey и chainKey
const { rootKey, chainKey } = await deriveRootAndChainKey(combined);
// ...
}
В классическом X3DH четвёртая DH-операция с one-time prekey опциональна: она выполняется, если сервер выдал доступный OPK получателя. В моей реализации устройство публикует набор one-time prekeys при регистрации, поэтому первое сообщение обычно использует DH4. Если OPK закончились, сессию всё равно можно установить через остальные DH-компоненты, но это уже менее сильный вариант.
Боб, получив конверт с эфемерным публичным ключом Алисы, повторяет те же операции со своими приватными ключами и получает тот же самый combined. Математика симметрична.
Сервер в этот момент видит только публичные ключи и зашифрованный конверт. Он помогает устройствам найти друг друга, но не участвует в вычислении секрета.
Получить combined только из публичных ключей практически невозможно: безопасность здесь опирается на свойства Diffie-Hellman на Curve25519. Поэтому сервер может хранить и отдавать prekey bundle, но не может вывести тот же shared secret, что получили устройства.

X3DH даёт нам стартовый chainKey. Но использовать один и тот же ключ для всех сообщений — плохая идея. Если использовать один ключ для всей переписки, компрометация этого ключа сразу открывает весь поток сообщений.
Решение — симметричный ratchet. После каждого сообщения цепочка ключей продвигается вперёд:
// crypto-engine.js — один шаг рatchet
async function ratchetStep(chainKeyBytes) {
const key = await crypto.subtle.importKey(
'raw', chainKeyBytes,
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
);
// messageKey — уникальный ключ для шифрования этого конкретного сообщения
const mkBits = await crypto.subtle.sign('HMAC', key, new Uint8Array([0x01]));
// nextChainKey — стартовый ключ для следующего сообщения
const ckBits = await crypto.subtle.sign('HMAC', key, new Uint8Array([0x02]));
const messageKey = await crypto.subtle.importKey(
'raw', new Uint8Array(mkBits),
{ name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
);
return { messageKey, nextChainKey: new Uint8Array(ckBits) };
}
Визуально это выглядит так:
chainKey₀ ──HMAC(·,0x02)──► chainKey₁ ──HMAC(·,0x02)──► chainKey₂ ──►
│ │ │
HMAC(·,0x01) HMAC(·,0x01) HMAC(·,0x01)
│ │ │
▼ ▼ ▼
messageKey₁ messageKey₂ messageKey₃
(AES-GCM msg #1) (AES-GCM msg #2) (AES-GCM msg #3)
messageKey используется для шифрования одного сообщения через AES-GCM, после чего уничтожается. Если атакующий компрометирует messageKey₂ — он прочитает только второе сообщение. chainKey₀ вывести из него невозможно — HMAC-SHA256 необратим.
В рамках такой симметричной цепочки это даёт forward secrecy назад по цепочке: зная текущий или отдельный messageKey, нельзя восстановить старые ключи. Но это ещё не полный Double Ratchet — об этом ниже.

Само шифрование сообщения:
async function encryptWithRatchet(session, plainText) {
const chainKeyBytes = b64ToBytes(session.sendingChainKey);
const { messageKey, nextChainKey } = await ratchetStep(chainKeyBytes);
// Продвигаем цепочку вперёд — старый chainKey больше не хранится
session.sendingChainKey = bytesToB64(nextChainKey);
session.sendingIndex++;
// Шифруем AES-GCM с уникальным nonce
const encrypted = await aesEncryptWithKey(plainText, messageKey);
return { encrypted, messageIndex: session.sendingIndex - 1 };
}
А вот что уходит на сервер — живой пример из DevTools:
{
"envelope": {
"ciphertext": "qzgHSg7zbwU6h8j8RqCPUYBWHJLi78eR9C0tj9I=",
"nonce": "6KPcVjbpM4FUB0Vz",
"senderIdentityPublicKey": "B4pERe0xKmSdiQPR+kLWWmI0nloC8Za3RBTg+occHF0=",
"targetDeviceId": "device-2aa3ae0e-ee08-4261-aa09-7d8f800b61e9",
"messageType": "PREKEY_WHISPER",
"messageIndex": 0
}
}
Сервер получает ciphertext и nonce. Расшифровать без messageKey — невозможно.

В этом проекте реализован Symmetric Ratchet — цепочка, где из chainKey для каждого сообщения выводится отдельный messageKey, а сама цепочка продвигается вперёд.
Это защищает прошлые сообщения: если атакующий узнает текущий ключ или отдельный messageKey, он не сможет откатить 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.
Значит при отправке сообщения нужно зашифровать его отдельно для каждого устройства каждого участника чата.
// crypto-engine.js — fanout на все устройства
async function buildFanoutRequest(api, chatId, plainText) {
const localBundle = await ensureDeviceRegistered(api);
// Получаем список всех устройств всех участников чата
const resolved = await api('/api/crypto/resolve-chat-devices/' + chatId, { method: 'POST' });
const envelopes = [];
for (const targetDevice of resolved.targetDevices) {
// Для своего устройства — особое шифрование (SELF_WHISPER)
if (targetDevice.deviceId === localBundle.deviceId) {
const encrypted = await encryptSelfEnvelope(localBundle, plainText);
envelopes.push({ ...encrypted, messageType: 'SELF_WHISPER' });
continue;
}
// Для чужого устройства — X3DH + Ratchet
let session = getSession(localBundle.deviceId, targetDevice.deviceId);
let ephemeralPublicKey = null;
if (!session) {
// Первое сообщение — устанавливаем X3DH сессию
const created = await createInitiatorSessionWrapped(localBundle, targetDevice);
session = created.session;
ephemeralPublicKey = created.ephemeralPublicKey;
}
const { encrypted, messageIndex } = await encryptWithRatchet(session, plainText);
storeSession(localBundle.deviceId, targetDevice.deviceId, session);
envelopes.push({
targetDeviceId: targetDevice.deviceId,
ciphertext: encrypted.ciphertext,
nonce: encrypted.nonce,
messageIndex,
ephemeralPublicKey, // null если сессия уже была
messageType: ephemeralPublicKey ? 'PREKEY_WHISPER' : 'WHISPER'
});
}
return { chatId, envelopes };
}
Для чата где у каждого по 2 устройства — 4 конверта на одно сообщение. Для группы из 10 человек — потенциально 20 конвертов. Это нормально, это цена безопасности.
На сервере сообщение создаётся с контентом [encrypted], а конверты сохраняются отдельно:
// MessageService.java
message.setContent("[encrypted]"); // сервер не знает что внутри
messageRepository.save(message);
// Каждый конверт — для конкретного устройства
Map<String, MessageEnvelope> byDevice = persistEnvelopes(message, sender, request.getEnvelopes());
После сохранения — fanout по WebSocket. Каждое устройство получает свой конверт и только его:
// MessageService.java — per-device доставка
private void fanoutCreatedEvent(Message message, Map<String, MessageEnvelope> byDevice) {
byDevice.forEach((deviceId, envelope) ->
messagingTemplate.convertAndSend(
"/topic/devices/" + deviceId + "/chats/" + message.getChatId(),
toDeviceEvent("MESSAGE_CREATED", message, envelope, envelope.getTargetUserId())
)
);
}
Это важное отличие от обычного WebSocket-чата. В обычном чате сервер рассылает одно и то же событие всем участникам. В E2EE-чате сервер рассылает разные события разным устройствам: payload для каждого устройства содержит свой ciphertext, зашифрованный под отдельную сессию.
Топик /topic/devices/{deviceId}/chats/{chatId} — строго персональный. Устройство А не получает конверт устройства Б. Никакого broadcast — только адресная доставка.
Браузер
├── React 18 + Vite
├── crypto-engine.js ← X3DH · Symmetric Ratchet · AES-GCM · WebCrypto
├── local device bundle ← identity key · signed prekey · one-time prekeys
├── REST /api/* ← auth · profile · chats · devices · prekeys
└── WebSocket /ws ← per-device STOMP topics
Spring Boot Backend
├── auth/ ← phone OTP · email · JWT · refresh tokens
├── crypto/ ← device registry · prekey bundles · envelope fanout
├── chat/ ← chats · participants · message metadata
├── message/ ← encrypted envelopes · receipts · events
├── infra/ws/ ← WebSocket · JWT auth · device routing
└── infra/presence/ ← online status · typing
PostgreSQL
└── users · devices · chats · messages([encrypted]) · envelopes(ciphertext, nonce)
Redis
└── refresh tokens · online presence · SMS rate limits
Observability
└── Actuator · Prometheus · Grafana

В панели чатов показывается превью последнего сообщения. Я реализовал это через ChatService.getMyChats() — загружаю последнее сообщение из БД и отдаю клиенту.
Запускаю — в списке чатов у всех написано [encrypted].
Конечно. Сервер же не знает что там написано.
Я полчаса думал как решить это на сервере. Потом дошло: нельзя решить это на сервере — у него нет ключей. Решение только на клиенте.
После того как пользователь открыл чат и сообщения расшифровались — кешируем последнее в памяти:
// После расшифровки сообщений в useMessages.js
previewCache.set(chatId, decryptedText.slice(0, 60));
// В компоненте ChatList — используем кеш
const preview = previewCache.get(chatId) ?? '🔒 Зашифровано';
Это хороший пример того, как E2EE меняет привычное backend-разработчика. В обычном приложении preview — это поле в SQL-запросе. В E2EE-приложении preview — это локальное клиентское состояние, потому что только клиент видел plaintext.
Простое решение. Но чтобы к нему прийти нужно было полностью принять идею что сервер здесь просто не при делах — и перестать пытаться решить задачу на его стороне.
Эндпоинт /api/auth/send-code отправляет SMS с кодом. Без защиты любой скрипт может дёргать его тысячи раз — это называется SMS pumping fraud, SMS стоят реальных денег.
Redis у нас уже был для хранения онлайн-статусов. Добавил rate limiting поверх него:
// SmsRateLimiter.java
public void checkAndIncrement(String phone) {
// Не более 3 SMS за 10 минут
checkLimit("sms:rate:short:" + phone, 3, Duration.ofMinutes(10));
// Не более 10 SMS за 24 часа
checkLimit("sms:rate:day:" + phone, 10, Duration.ofHours(24));
}
private void checkLimit(String key, int maxAttempts, Duration window) {
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, window);
}
if (count > maxAttempts) {
long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
throw new RateLimitException("Too many requests", ttl);
}
}
При превышении — HTTP 429 с заголовком Retry-After. Клиент знает через сколько секунд можно повторить.
Важный нюанс: в текущей реализации, если Redis недоступен, сервис не блокирует авторизацию полностью. Для pet-проекта это приемлемый компромисс: лучше рискнуть одним лишним SMS, чем положить вход в приложение.
В production я бы сделал строже: fallback in-memory лимит на инстанс, отдельные лимиты по IP и телефону, антифрод-логику и алерты на всплески отправки кодов.
Отдельная история — авторизация WebSocket соединений. HTTP-эндпоинты защищены Spring Security автоматически, но WebSocket — другое дело. STOMP-соединение устанавливается один раз, и нужно проверять JWT при каждом подключении.
// WebSocketAuthChannelInterceptor.java
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
throw new AuthException("Missing WebSocket auth token");
}
// Валидируем JWT и устанавливаем principal
Authentication auth = jwtAuthProvider.authenticate(token.substring(7));
accessor.setUser(auth);
}
return message;
}
Отдельно важно не только проверить JWT, но и связать WebSocket-соединение с конкретным устройством. Пользователь может быть один, но устройств у него несколько, а encrypted envelope адресован именно deviceId.
Поэтому при подключении я проверяю не только токен, но и X-Device-Id: устройство должно быть зарегистрировано и принадлежать текущему пользователю. Иначе легко случайно превратить 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 [2]
В репозитории есть README на русском и английском, диаграммы, скриншоты, security audit, Docker Compose и запуск одной командой.
Проект не претендует на уровень production-криптомессенджера вроде Signal. Это учебный и инженерный open-source прототип, цель которого — показать, как E2EE меняет архитектуру backend, frontend и realtime-доставки.
Если вы делали что-то похожее — особенно интересно сравнить подходы к ротации prekey-ов, хранению non-extractable ключей в браузере и реализации DH ratchet step. Вопросы и критика приветствуются.
Автор: grokfrog
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/450891
Ссылки в тексте:
[1] мышление: http://www.braintools.ru
[2] github.com/vaazhen/chaos-messenger: https://github.com/vaazhen/chaos-messenger
[3] Источник: https://habr.com/ru/articles/1030854/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1030854
Нажмите здесь для печати.