Привет, Хабр.

Я 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 выглядят примерно так:

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

Главный вопрос: как Алиса и Боб получают общий секрет, если они никогда раньше не общались? И как сделать это так, чтобы сервер только помог передать публичные данные, но сам не смог вычислить итоговый ключ?

Для этого используется 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-приложениях обычно приходится выбирать между несколькими вариантами:

  1. Сериализуемые ключи в localStorage или IndexedDB — проще реализовать, но нужно очень серьёзно относиться к XSS и целостности frontend-кода.

  2. extractable: false + IndexedDB — безопаснее, но сложнее в реализации и восстановлении состояния.

  3. Нативное 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, что получили устройства.



Как шифруется каждое сообщение: Symmetric Ratchet

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 — невозможно.



Важная оговорка: это ещё не полный Double Ratchet

В этом проекте реализован 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.

Простое решение. Но чтобы к нему прийти нужно было полностью принять идею что сервер здесь просто не при делах — и перестать пытаться решить задачу на его стороне.


Rate limiting: дыра которую легко не заметить

Эндпоинт /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

Отдельная история — авторизация 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

В репозитории есть README на русском и английском, диаграммы, скриншоты, security audit, Docker Compose и запуск одной командой.

Проект не претендует на уровень production-криптомессенджера вроде Signal. Это учебный и инженерный open-source прототип, цель которого — показать, как E2EE меняет архитектуру backend, frontend и realtime-доставки.

Если вы делали что-то похожее — особенно интересно сравнить подходы к ротации prekey-ов, хранению non-extractable ключей в браузере и реализации DH ratchet step. Вопросы и критика приветствуются.

Комментарии (4)


  1. ProTechSpec
    03.05.2026 15:49

    Мне понравилось, довольно хорошо описано. Механизм шифрования понятный, envelope описан ясно. Желаю успехов в развитии данного проекта, ведь тема таких мессенджеров сейчас как никогда актуальна.


    1. grokfrog Автор
      03.05.2026 15:49

      Спасибо!


  1. Grey_Box
    03.05.2026 15:49

    Этаж чё получается... Мошенники могут общаться в нем и обманывать людей? А у правоохранительных органов доступа нету? ЗАПРЕТИТЬ!)


    1. grokfrog Автор
      03.05.2026 15:49

      █████████████████████████████████▶ 75% начинаю удаление репозитория…