Как я делал мессенджер, который на проводе выглядит как обычный HTTPS: пять инженерных историй

—

от автора

В какой-то момент я решил написать свой мессенджер. Не потому, что мне не хватало ещё одного, а потому, что три вещи в существующих не лечатся настройками: у них есть центр, который можно выключить; они требуют номер телефона; и их видно на проводе, даже когда всё зашифровано. Так появился Призрак — федеративный мессенджер со сквозным шифрованием, у которого трафик, включая звонки, выглядит как поход на сайт по HTTPS, и со встроенным VPN в два прыжка.

В этой статье не будет обзора функций — их полно в любом мессенджере. Вместо этого пять историй о том, где я ошибался и что понял: почему шифрование и незаметность — разные задачи, как ловится «зашифрованный» звонок по одному пакету, зачем сети узлов-тайников модель из Ceph, как я потратил неделю на константу 2048 и почему гудки в звонке оказались сложнее, чем сам звонок.

Десктоп-клиент Призрака

Десктоп-клиент Призрака

Снаружи это обычный мессенджер: чаты, группы, каналы, звонки, голосовые. Вся интересная часть — под капотом.

Что вообще построено

Чтобы дальнейшие истории были понятны, коротко о конструкции.

Что

Как

Идентификатор

имя:домен, без номера телефона и почты

Шифрование

OpenPGP-паспорт + X3DH + Double Ratchet, ChaCha20-Poly1305

Федерация

свой homeserver на своём домене; серверы находят друг друга сами

Транспорт

настоящий TLS 1.3 к настоящему домену, мультипорт, страница-обманка при зондировании

Когда серверы не видят друг друга

сеть узлов-тайников, которые не могут прочитать содержимое

Звонки

свой нативный медиастек, свой аналог STUN, P2P или релей

VPN

встроенный, два прыжка, свой протокол на проводе

Клиенты

Electron (Windows / macOS / Linux), React Native (Android; iOS в работе)

Лицензия

AGPL-3.0

Сервер здесь — почтальон, который носит запечатанные конверты. Он видит «от кого, кому, когда» и шифртекст. Ключей у него нет, и это свойство кода, а не декларация: в таблице сообщений лежит поле payload, и открыть его нечем.

Как устроена доставка

Как устроена доставка

История 1. Шифрование и незаметность — это разные задачи

Первое, что я понял на старте: можно зашифровать всё идеально и всё равно быть опознанным по первым восьми байтам пакета. DPI смотрит не в содержимое, а в форму.

У обычного WebRTC-звонка две сигнатуры, и обе — открытым текстом:

  • STUN/ICE. На смещении 4 в каждом пакете лежит magic cookie 0x2112A442. Это константа из RFC 5389, она обязана быть там. Фильтр опознаёт её за один пакет.

  • DTLS-SRTP. Record type 22 и версия 0xFEFD в начале медиапотока. Тоже чёткая сигнатура.

Отсюда вывод, который дорого дался: маскировать надо весь транспорт, а не «сигналинг». Прятать управляющий канал бессмысленно, если медиапоток кричит о себе первыми байтами.

Второй вывод — не имитировать HTTPS, а быть им. Подход в духе Reality/XTLS: клиент проводит настоящий TLS 1.3-хендшейк с настоящим сертификатом сервера. Право на туннель доказывается скрытым токеном уже внутри зашифрованного канала. Если по адресу постучится цензор без токена — сервер отдаст ему обычную страницу и закроет соединение. Аномалии нет, блокировать не за что.

Сервер слушает сразу пачку стабильных портов (443, 8801, 80, 993, 995, 587, 465, 143, 110, 25), молча пропуская занятые. Клиент подключается по адресу без порта и сам находит рабочий, начиная с 443. Чем больше независимых точек входа, тем дороже блокировка, и тем меньше пользователь знает слово «порт».

Честная оговорка. Я сознательно не стал писать «свою обфускацию поверх TLS» и называть её неотличимой. Настоящий TLS на 443 с реальным сертификатом даёт фильтру гораздо меньше зацепок, чем любой самодельный протокол-мимикрия. Но «меньше зацепок» — не «невидимо»: тайминги и объёмы никуда не деваются, а отпечаток ClientHello под браузер — отдельная задача, которая ещё в работе.

История 2. Почему «просто PGP» — правильная интуиция и неправильное решение

Изначально хотелось: открытый ключ, закрытый ключ, всё понятно. Для потока сообщений это плохо по трём причинам.

Нет forward secrecy: все сообщения к вам шифруются на один долговременный ключ, утёк один раз — расшифровывается история за годы. Нет post-compromise security: после компрометации устройства PGP не «самолечится». И управление ключами — исторически главный источник ошибок у обычных людей.

Но PGP делает отлично две вещи, и за них он остался в проекте. Долговременный ключ — это паспорт личности, отпечаток которого сверяют лично по QR. И он подписывает эфемерные ключи: все X25519-prekeys подписаны OpenPGP-ключом, и именно эта подпись ловит MITM. Сервер, раздающий чужой prekey вместо вашего, не сможет его подписать; в тестах подмена bundle отвергается ровно по этому.

OpenPGP-ключ (долговременный) ──подписывает──▶ X25519 identity + prekeys                                                        │                                           X3DH: первый общий секрет                                                        │                                            Double Ratchet (поток 1:1)                                  forward secrecy + post-compromise security

Доставка не по порядку поддержана через skipped message keys — иначе мобильная сеть развалит переписку на первом переключении вышки.

Мультидевайс без общего ключа. Классическая ловушка: «положим один ключ на все устройства». Украли планшет — скомпрометирован аккаунт целиком, отзывать нечего. У меня два уровня: корень аккаунта (тот же OpenPGP-ключ, восстанавливается сид-фразой из 17 слов) и у каждого устройства свой X25519 identity, подписанный корнем и опубликованный в реестре устройств. Отправитель шифрует сообщение отдельно на каждое устройство получателя отдельной ратчет-сессией. Отозвали устройство — оно перестаёт получать конверты, остальные живут. Цена — O(участники × устройства) шифрований; для больших групп это лечится MLS (RFC 9420), который спроектирован, но пока не интегрирован.

Скриншоты: устройства и вход
Список устройств аккаунта

Список устройств аккаунта
Экран входа: имя и пароль, домен подставляется сам

Экран входа: имя и пароль, домен подставляется сам

История 3. Что делать, когда серверы не видят друг друга

Ситуация: серверы на доменах Д1 и Д2 перестали видеть друг друга — IP одного забанен у другого. Но оба видят какой-то третий узел. Этого достаточно.

Ключевое решение — доставка через pull. Под фильтрацией надёжнее, когда оба конца соединяются исходяще к общему узлу. Д1 кладёт конверт в тайник, Д2 сам его забирает. Входящая достижимость не нужна никому.

Тайник ничего не знает. Адрес ящика — это HKDF(публичный ключ сервера-получателя, эпоха), а не домен. Узел видит непрозрачный меняющийся идентификатор и шифртекст. Дедупликация — по контент-адресу, msgId = хеш(шифртекста).

Конверт кладётся сразу на 4 узла. Получатель забирает первую попавшуюся копию и рассылает всем репликам подписанный ACK; узел, получивший ACK, удаляет блоб. ACK — единственный источник правды о доставке: вернувшийся из офлайна узел спрашивает у живых «есть ACK?» и, если есть, просто удаляет своё протухшее.

Самоисцеление я не стал изобретать, а взял модель RADOS из Ceph и перенёс её на тайники:

Ceph

Тайники

CRUSH — размещение без центральной таблицы

Rendezvous-хеширование: любой сам вычисляет, где лежит блоб

Cluster map + эпоха

Реестр узлов с эпохой, расходится госсипом

size / min_size

RF=4 / min_size=2

Placement Group

Бакет блобов, реплики сверяются по Merkle-корню

Backfill / recovery

Доливка копий до RF при уходе узла

Peering при возврате OSD

Merkle-ресинк: удалить доставленное, подтянуть недостающее

Иммутабельность сильно упрощает жизнь по сравнению с Ceph: блобы write-once, удаляются по ACK или по TTL в 7 дней. Нет мутаций — не нужны версии, строгий порядок записи и кворумы. Только «есть копия / нет копии / есть ACK» плюс anti-entropy.

Поправка на цензуру: связь «узел↔узел» тоже могут перерезать. Поэтому базовая гарантия — отправитель сразу кладёт на 4 узла (этот путь заведомо рабочий), самоисцеление — best-effort сверху, страховка — периодический ре-фан-аут недоставленного.

Отдельная головная боль — как узлы находят друг друга, если любой статический список — подарок цензору. Опубликовал файл со списком — заблокировали все разом. Поэтому директория — это набор подписанных объектов, которые может отдать кто угодно: другой сервер, тайник, зеркало, CDN, DNS-запись через DoH. Принцип Керкгоффса: считаем, что противник знает код и весь каталог; безопасность — в подписи (нельзя отравить), во множестве входов (нельзя заблокировать все) и в стелс-транспорте. Дотянулся до одного живого узла — развернул весь актуальный каталог. Реестр отдаётся порционно и с рейт-лимитом, как BridgeDB у Tor, а приватные мосты не госсипятся вообще.

Тем же механизмом клиенты обновляют и адреса зеркал: маленький подписанный JSON со сроком годности ищется одновременно в TXT-записи через dns.google и cloudflare-dns.com и на каждом зеркале; первый верный документ побеждает, а часы клиента проверяются по заголовку Date ответа, потому что на телефонах они врут чаще, чем хотелось бы.

Скриншоты: узел-тайник и свой сервер
Prizrak-Node — узел-тайник

Prizrak-Node — узел-тайник
Prizrak-Server — свой homeserver

Prizrak-Server — свой homeserver

История 4. Как я потратил неделю на 2048 байт

Самая поучительная история проекта. Держите как чек-лист, если будете делать своё медиа.

Звонки тормозили и грели телефон. Виновато было не шифрование (аппаратный AEAD — доли процента CPU), а то, что каждый видеокадр гонялся через мост React Native в JavaScript как base64, и весь поток шёл через сервер. Решение очевидное: медиа целиком в натив (Camera2 / MediaCodec / AudioRecord / Opus), JS занимается только сигналингом и UI. Ноль base64 на кадр.

Публичный STUN использовать нельзя — см. magic cookie. Вместо него сервер сам работает зеркалом адресов: клиент шлёт один UDP-пакет, оформленный по форме как QUIC initial (long header, случайный connection ID), сервер отвечает, какой публичный ip:port он увидел. Функция STUN без единого узнаваемого байта. Дальше — обмен кандидатами по уже зашифрованному сигналингу и одновременный hole-punch.

И вот тут была ошибка. После включения прямого P2P-пути звук стал отличным, а видео посыпалось артефактами. Разница между звуком и видео — размер пакетов: Opus-кадр — сотни байт, VP8-кадр — десятки килобайт. Приёмный UDP-буфер был 2048 байт. Аудио проходило целиком, видеокадры обрезались, AEAD не сходился, кадр терялся. Неделя ушла на то, чтобы перестать подозревать кодек.

Что починило по-настоящему:

  • буфер приёма 2048 → 65536 и фрагментация по MTU;

  • приёмный гейтинг: после любой потери дельта-кадры отбрасываются и шлётся PLI до нового ключевого кадра. Вместо «сыпучки» — короткий фриз с чистым восстановлением;

  • битрейт по загрузке канала, а не по потерям: на TCP-релее loss-based адаптация слепа (потерь нет), поэтому энкодер регулируется по длине очереди отправки — растёт очередь, резко сбавляем; свободно — аккуратно поднимаем.

У мобильных операторов почти всегда CGNAT, так что релей — рабочая лошадка, а прямой канал выигрывает на Wi-Fi. Индикатор пути (🔗 / 📡) с потерями и битрейтом виден прямо в звонке.

История 5. Гудки оказались сложнее звонка

Эту историю я дописываю буквально сегодня. Пользователь позвонил другу и пожаловался: «у меня нет гудков, а у него просто всплыло, что кто-то звонит». Всё работало, но ощущалось сломанным — потому что за сто лет телефонии люди привыкли, что вызов звучит.

Оказалось, что «гудки» — это не звук, а состояние. Чтобы сыграть правильный гудок, надо знать, что происходит на том конце, а сигналинг мессенджера этого не сообщает: offer ушёл, и тишина до answer. Пришлось добавить проверку присутствия после отправки offer и завести полноценный автомат состояний: собеседник в сети → длинные гудки; не в сети → короткие «недоступен», 4 секунды и отбой; пришёл hangup до answer → «занято»; нет answer за 60 секунд → «нет ответа». Частота — 425 Гц, как у городских АТС; каденции взял ближе к привычным, чем к ГОСТу (там КПВ 1 с / 4 с, а «занято» 0,4 / 0,4 — на слух в приложении это звучит чужеродно).

Попутно вылезли настоящие баги, которые без гудков просто не были видны. «Отклонить» на десктопе не слало звонящему ничего — сигнал уходил только после принятия, и у звонящего вызов висел до таймаута. Если звонящий клал трубку первым, окно входящего на десктопе продолжало звенеть. Второй входящий во время разговора молча терялся. И вибрации на телефоне в режиме «вибро» не было, потому что рингтон игрался через MediaPlayer, а вибрация — отдельная сущность с отдельными атрибутами (и в режиме «без звука» её быть не должно).

Последний штрих — громкая связь по датчику приближения: телефон не у уха — гудки и разговор идут в динамик, поднёс к уху — в разговорный, экран гаснет. Ручной тап по кнопке динамика выключает автоматику до конца звонка, иначе она спорит с человеком.

Мораль: пользовательская привычка — это спецификация, просто ненаписанная.

Встроенный VPN в два прыжка

Раз стелс-транспорт уже есть, странно не переиспользовать его. Требования были жёсткие: никаких WireGuard/OpenVPN/L2TP на проводе — их сигнатуры фильтры знают давно; два прыжка (клиент → промежуточный узел в стране пользователя → выход за границей: приманка знает ваш IP, но не знает, куда вы идёте; выход знает назначение, но не знает, кто вы); локальный tun живёт только внутри устройства; и трафик самого мессенджера идёт мимо туннеля, иначе петля (на Android — addDisallowedApplication для себя, на десктопе — маршруты-исключения).

Три решения, которыми доволен: доступность узла проверяется изнутри уже установленной сессии, без пинга (пинговать — значит светить и узел, и себя), с переключением make-before-break; страна узла определяется по GeoIP наблюдаемого адреса, а не по заявке оператора; рейтинг узлов байесовский, со свежестью 60 дней и антинакруткой.

Экран VPN в клиенте

Экран VPN в клиенте

Отдельно про экономику, потому что этот вопрос стоит задавать любому децентрализованному проекту с внутренней валютой: что мешает админу чужого сервера начислить себе миллион, код-то открытый? Ответ неромантичный: баланс нельзя хранить на самохостящихся серверах. Сообщения, группы, звонки, файлы, ключи — полностью децентрализованы. Деньги — единый реестр, и каждая операция подписывается отдельным Ed25519-ключом пользователя, приватной части которого у админа сервера нет. Ключ привязан к user:domain по TOFU. Форк может выпустить «свою валюту», но это будет другой банк.

Скриншоты: статистика трафика и распределённое хранилище
Статистика трафика по приложению

Статистика трафика по приложению
Prizrak-Drive: зашифрованное хранилище в трёх копиях

Prizrak-Drive: зашифрованное хранилище в трёх копиях

Что не сделано и где слабые места

Раздел, ради которого стоит читать любую статью про безопасность.

  • Метаданные. E2E не прячет от вашего homeserver’а сам факт переписки: кто, с кем, когда. Снижается минимизацией логов и своим сервером, но не убирается. Абсолютной невидимости не существует — цель в том, чтобы деанонимизация и блокировка стоили непропорционально дорого.

  • Своя криптография. Реализация ратчета покрыта тестами, но для продакшена нужны зрелые библиотеки и независимый аудит. «Катать свою крипту» — плохая идея, и я это признаю прямо.

  • Key transparency — в планах, не в коде. Сейчас защита от подмены ключей — подпись prekeys паспортом и сверка отпечатков вживую.

  • MLS спроектирован, но группы работают через per-device fan-out: корректно, но не для очень больших групп.

  • Одноразовые prekeys переиспользуются сервером: X3DH деградирует к безопасному no-OTK варианту, но для строгой forward secrecy нужны single-use OTK с пополнением.

  • Отпечаток ClientHello под браузер и автовыпуск сертификатов — ещё в работе.

  • iOS — код есть, на устройствах не обкатан.

Ничего из этого не мешает пользоваться мессенджером сегодня, но знать об этом стоит.

Из чего это собрано

Монорепозиторий на JavaScript: ядро (крипта, транспорт, клиентская библиотека) переиспользуется десктопом и мобилкой, натив появляется только там, где без него никак — медиа и VPN-сервис на Android. Сервер — Node.js + SQLite в режиме WAL. Свой homeserver поднимается одним скриптом:

cd packages/server && npm install./deploy/prizrak-deploy.sh init --domain chat.example.org --admin root --registration on./deploy/prizrak-deploy.sh create-admin --password 'PASSWORD'./deploy/prizrak-deploy.sh start

Узел-тайник — cd packages/deaddrop && npm install && node src/node.js, нужен любой компьютер с Node.js и постоянным интернетом.

Клиенты и подробное описание архитектуры с диаграммами — на prizrak.im и ru.prizrak.im/how.

Буду рад разбору по существу — особенно от тех, кто занимался DPI, транспортами и групповой криптографией. Критика по делу здесь полезнее плюсов.


Юридическое замечание: средства обхода блокировок защищают приватность, но их легальность различается по юрисдикциям. Проект будет развиваться как open source для приватности и свободы общения; ответственность за соблюдение применимого закона лежит на пользователе.

ссылка на оригинал статьи https://habr.com/ru/articles/1091212/