
Зачем ещё один мессенджер
Обычно на этот вопрос отвечают лозунгами. Я попробую ответить инженерно — через то, что ломается в существующих решениях.
Первое: центр. У Telegram, WhatsApp и Signal есть организация, у которой можно что-то потребовать, и инфраструктура, которую можно заблокировать целиком. Даже если переписка зашифрована, сам факт «кто с кем и когда» лежит в одном месте, и это место можно выключить. Проблема не в злом умысле компании — проблема в том, что такая точка вообще существует.
Второе: номер телефона. Номер — это ваш паспорт, привязанный к SIM-карте, которая куплена по документам. Мессенджер, который требует номер, по построению не может быть анонимным.
Третье: транспорт. Даже идеально зашифрованный мессенджер бесполезен, если DPI видит его на проводе и режет соединение. Здесь важна деталь, которую часто упускают: шифрование и незаметность — это разные задачи. Можно зашифровать всё идеально и всё равно быть опознанным по первым восьми байтам пакета.
Prizrak («Призрак») — попытка закрыть все три пункта сразу, не жертвуя удобством: чаты, группы, каналы, звонки, голосовые, «кружочки», реакции, подарки — всё, к чему люди привыкли в Telegram.
Что получилось: коротко
| Что | Как сделано |
|---|---|
| Идентификатор | имя:домен, без номера телефона и без почты |
| Шифрование | OpenPGP-паспорт + X3DH + Double Ratchet; для больших групп спроектирован MLS (RFC 9420) |
| Федерация | Свой homeserver на своём домене, серверы находят друг друга сами |
| Транспорт | Настоящий TLS 1.3 к настоящему домену, мультипорт, страница-обманка при зондировании |
| Обход блокировок | Сеть «тайников» — промежуточных узлов, которые не могут прочитать содержимое |
| Звонки | Свой нативный медиастек, свой аналог STUN, P2P или релей |
| VPN | Встроенный, в два прыжка, со своим протоколом на проводе (движок готов, обвязка в работе) |
| Клиенты | Electron (macOS / Windows / Linux) и React Native (Android, iOS в работе) |
| Лицензия | AGPL-3.0 |

Три опоры
Вся система стоит на трёх вещах. Их нельзя «выключить по требованию» — они вшиты в архитектуру.

1. Федерация без центра
Нет главного сервера. Любой поднимает свой homeserver на своём домене, и его пользователи сразу общаются с пользователями других серверов. Ровно как электронная почта: серверов много, сеть одна.
Пользователь адресуется как alice:a.org — имя и домен домашнего сервера. Когда alice:a.org пишет bob:b.org, сервер a.org смотрит на домен получателя, находит сервер b.org и передаёт ему конверт.
2. Сквозное шифрование
Сервер — тупой ретранслятор запечатанных конвертов. Он видит маршрут (от кого, кому, когда) и шифртекст. Больше ничего. Ни владелец сервера, ни провайдер, ни тот, кто сервер изъял, не прочитают переписку: ключи есть только у собеседников.
Это не декларация, а свойство кода: сервер хранит поле payload, в котором лежит шифртекст, и у него нет ключей, чтобы его открыть.
3. Невидимость для DPI
Трафик мессенджера и звонков должен выглядеть как обычный веб-сёрфинг. Не «похоже на HTTPS», а буквально HTTPS: настоящий TLS 1.3-хендшейк с настоящим сертификатом сервера. Обнаруживать нечего, потому что ничего не имитируется — рукопожатие честное.
Федерация: как это работает на самом деле
Идея взята у Matrix, но сознательно упрощена.
Discovery. Классический путь — GET https://домен/.well-known/prizrak/server, который отдаёт базовый URL. Но я пошёл дальше: прописывать чужие серверы в конфиг не нужно вообще. Сервер сам сканирует стандартные порты соседа — 443, 8801, 80, 993, 995, 587, 465, 143, 110, 25 — и находит рабочую точку входа, кэшируя результат на 5 минут. Строчка resolver в конфиге осталась только как необязательный пин, если у соседа нестандартный адрес.
Почему это важно: любой обязательный конфиг — это точка отказа и ручная работа. Сменился адрес узла — и надо править файлы на всех серверах сети. Живое обнаружение снимает эту боль полностью.
Два API. Клиент-серверное и сервер-серверное:
| Уровень | Метод | Путь | Назначение |
|---|---|---|---|
| Клиент | POST | /_prizrak/client/v1/register |
опубликовать профиль и prekey-bundle |
| Клиент | GET | /_prizrak/client/v1/bundle?userId= |
взять ключи собеседника (в т.ч. с чужого домена) |
| Клиент | POST | /_prizrak/client/v1/send |
отправить запечатанный конверт |
| Сервер | POST | /_prizrak/federation/v1/send |
принять конверт для своего пользователя |
| Сервер | GET | /_prizrak/federation/v1/bundle?userId= |
отдать ключи своего пользователя |
Чем проще Matrix. Matrix реплицирует между серверами полный граф состояния комнаты с разрешением конфликтов (state resolution). Это мощно и тяжело. Для мессенджера «как Telegram» это избыточно: сообщения — это поток запечатанных событий с доставкой store-and-forward, а состояние группы решается на уровне E2E-группы, а не открытого серверного стейта.
Хранилище — SQLite в режиме WAL с нормальными таблицами и индексами (раньше был JSON-файл; миграция прошла с автопереносом и 34 тестами на стор).
Шифрование: почему «чистый PGP-мессенджер» — плохая идея
Изначально хотелось «просто PGP»: открытый ключ, закрытый ключ, всё понятно. Это правильная интуиция про асимметричную криптографию — и неправильное решение для потока сообщений. Разберём честно.

Что не так с PGP как транспортом переписки:
- Нет forward secrecy. В PGP все сообщения к вам шифруются на один и тот же долговременный ключ. Ключ утёк один раз — расшифровывается вся история за годы. У ратчета для каждого сообщения свой одноразовый ключ.
- Нет post-compromise security. После компрометации устройства PGP не «самолечится». Double Ratchet при следующем обмене DH восстанавливает секретность — злоумышленник с украденным старым ключом выпадает из переписки.
- Управление ключами. Ротация, отзыв, выбор правильного ключа — исторически главный источник ошибок у обычных людей.
Что при этом PGP делает отлично — и за что он остался в проекте:
- Паспорт личности. Долговременный ключ — это устойчивая идентичность. Его отпечаток пользователи сверяют лично: по QR или по видеозвонку.
- Подпись эфемерных ключей. Все X25519-prekeys подписываются OpenPGP-ключом. Именно эта подпись ловит MITM: сервер, раздающий чужой ключ вместо вашего, не сможет его подписать. В тестах подмена prekey-bundle отвергается именно по подписи.
- Оффлайн-конверты. Инвайты, пуш-нагрузка, резервные копии аккаунта — здесь классическое PGP-шифрование к публичному ключу подходит идеально.
Итоговая схема:
OpenPGP-ключ (долговременный) ──подписывает──▶ X25519 identity + prekeys
│
X3DH: первый общий секрет
│
Double Ratchet (поток 1:1)
forward secrecy + post-compromise security
Под капотом: X25519 для DH-ратчета, HKDF-SHA256 для root-цепочки, HMAC-SHA256 для symmetric-цепочек, ChaCha20-Poly1305 как AEAD. Доставка не по порядку поддержана (skipped message keys) — иначе мобильная сеть развалит переписку на первом же переключении вышки.
Для больших групп попарные сессии не масштабируются: O(n) шифрований на каждое сообщение. Для этого в архитектуру заложен MLS (RFC 9420) — групповой E2E, где сообщение шифруется один раз независимо от размера группы, а смена состава стоит O(log n). MLS рассчитан на недоверенную службу доставки, что ложится на федеративную модель идеально. Оговорка: MLS спроектирован, но пока не интегрирован — сегодня группы работают через per-device fan-out (см. раздел про ограничения).
Про защиту от подмены ключей честно. Защита многослойная: подпись prekeys паспортом, сверка отпечатка вживую, а для продакшена нужен публичный key transparency-лог (append-only, как CONIKS), чтобы подмена ключа была публично обнаружима. KT-лога пока нет — это в планах, и я это не скрываю.
Транспорт: почему обычный звонок ловится DPI за один пакет
Это самая недооценённая часть. Люди думают: «Раз всё зашифровано, DPI ничего не увидит». Увидит. DPI смотрит не в содержимое, а в форму.

У обычного WebRTC-звонка есть две сигнатуры, обе открытым текстом:
- STUN/ICE: на смещении 4 в пакете лежит magic cookie
0x2112A442. Это константа из стандарта, она обязана быть там. Опознаётся мгновенно. - DTLS-SRTP: record type
22+ версия0xFEFDв начале медиапотока. Тоже чёткая сигнатура.
Вывод, который мне пришлось принять на старте: маскировать надо не «сигналинг», а весь транспорт целиком. Прятать управляющий канал бессмысленно, если сам медиапоток кричит о себе первыми байтами.
Решение: не имитировать HTTPS, а быть им
Лучшее, что реально работает против современных фильтров, — подход в духе Reality/XTLS. Идея контринтуитивная: не подделывать TLS, а провести настоящий TLS 1.3-хендшейк.
- На проводе — валидная TLS-запись с реальным сертификатом (сейчас сертификат подкладывается вручную; полноценный Reality-режим с живым сайтом-прикрытием и uTLS-фингерпринтом Chrome — в планах).
- Право на туннель клиент доказывает скрытым токеном внутри уже зашифрованного канала — снаружи его не видно.
- Probe-resistance: если по адресу постучится цензор без валидного токена, сервер прозрачно отдаёт ему обычную страницу и закрывает соединение. Аномалии нет — блокировать не за что.
- Длины медиакадров добиваются случайным паддингом, чтобы статистический анализ не выцепил характерный размер аудиопакетов.
Мультипорт и «клиент сам найдёт дорогу»
Сервер слушает сразу список стабильных портов и молча пропускает те, что заняты или требуют прав. Клиент подключается по адресу без порта и сам сканирует, начиная с 443. Если задан сертификат — 443, 993, 995 и 465 поднимаются как настоящий HTTPS/WSS.
Смысл простой: чем больше независимых точек входа, тем дороже блокировка. И тем меньше вопросов у пользователя, который просто вводит имя:домен и не должен знать слово «порт».
Честная оговорка. Я специально не стал делать «свою обфускацию поверх TLS» и заявлять, что она неотличима. Настоящий TLS на 443 с реальным сертификатом даёт фильтру гораздо меньше зацепок, чем любой самодельный протокол-мимикрия. Но «меньше зацепок» — это не «невидимо»: тайминги и объёмы трафика никуда не деваются, а кастомная мимикрия — отдельный крупный R&D, и называть её невидимой голословно нечестно.
Звонки: как я потратил неделю на 2048 байт
Самая поучительная инженерная история проекта. Держите её как чек-лист, если будете делать своё медиа.
Диагноз на старте. Звонки тормозили и грели телефон. Виновато было не шифрование (аппаратный AES — доли процента CPU), а две вещи:
- Каждый видеокадр гонялся через мост React Native в JavaScript как base64. Чудовищные накладные расходы на каждый кадр.
- Весь поток шёл только через сервер.
Решение: медиа целиком уходит в натив (Camera2 / MediaCodec / AudioRecord / Opus), JS занимается только сигналингом и UI. Ноль base64 на кадр.
Свой аналог STUN. Публичный STUN не годится — см. magic cookie выше. Вместо него сервер сам работает «зеркалом адресов»: клиент шлёт один UDP-пакет, оформленный по форме как QUIC-initial (long header, случайный connection ID), сервер отвечает, какой публичный ip:port он увидел. Это ровно функция STUN, но без единого узнаваемого байта на проводе. Дальше — обмен кандидатами по уже зашифрованному сигналингу и одновременный hole-punch.
И вот тут была та самая ошибка. После включения прямого P2P-пути звук в видеозвонке стал отличным, а видео посыпалось артефактами и фризами. Разница между звуком и видео — размер пакетов: Opus-кадр это сотни байт, VP8-кадр — десятки килобайт. Приёмный UDP-буфер был 2048 байт. Мелкое аудио проходило целиком, крупные видеокадры обрезались, AEAD не сходился, кадр терялся.
Правки, которые всё починили:
- буфер приёма
2048 → 65536; - видео временно переведено на релей (TCP держит большие кадры целиком и по порядку), прямой путь оставлен аудио — до MTU-фрагментации, которую я доделал следующим релизом;
- приёмный гейтинг: после любой потери дельта-кадры отбрасываются и шлётся PLI до нового ключевого кадра. Вместо «сыпучки» — короткий фриз с чистым восстановлением;
- битрейт по загрузке канала. На TCP loss-based адаптация слепа (потерь-то нет), поэтому энкодер регулируется по длине очереди отправки: очередь растёт — резко сбавляем, свободно — аккуратно поднимаем.
Топология. Прямой P2P, если NAT позволяет; иначе релей через серверы. У мобильных операторов почти всегда CGNAT, поэтому релей — рабочая лошадка, а прямой канал выигрывает на Wi-Fi. Индикатор пути (🔗 / 📡) виден прямо в звонке вместе с потерями и битрейтом.
Про кодеки и iOS честно. Согласование кодека в offer уже есть: по умолчанию VP8 (совместимость с десктопом), H.264 — в тестовом режиме на Android. Осталось научить десктоп принимать H.264 (WebCodecs) и портировать нативный стек на iOS. И там жёсткое ограничение: у iOS нет аппаратного VP8, VideoToolbox умеет H.264/HEVC. Сам iOS-клиент сейчас в состоянии «код написан, на устройстве не проверен» — сборка требует Mac с Xcode, а аудиомодуль писался вслепую и почти наверняка потребует правок.
И честно про текущее состояние на мобильном: видеозвонки сейчас снова выключены. Более поздняя регрессия роняла вместе с видео и аудио (похоже, отрисовка в мёртвый EGL-контекст после уничтожения SurfaceView), и я убрал кнопку до аккуратного пересбора видеопути — с гарантией, что видео не может уронить звук. Аудиозвонки работают.
Сеть тайников: что делать, когда прямой путь перерезан
Ситуация: серверы на доменах Д1 и Д2 не видят друг друга — IP одного забанен у другого. Но оба видят какой-то третий узел. Этого достаточно.

Ключевое решение: доставка через pull. Под фильтрацией надёжнее, когда оба конца соединяются исходяще к общему узлу. Д1 кладёт конверт в тайник, Д2 сам его забирает. Никому не нужна входящая достижимость от другого — только исходящая к тайнику.
Тайник ничего не знает. Адрес ящика — это токен HKDF(публичный ключ сервера-получателя, эпоха), а не домен. Узел не знает, чей это ящик, видит только непрозрачный меняющийся идентификатор и шифртекст. Дедупликация — по контент-адресу (msgId = хеш(шифртекста)).
Кто именно доставляет. Конверт кладётся сразу на 4 узла (RF=4). Получатель забирает первую попавшуюся копию и рассылает всем репликам подписанный ACK. Узел, получивший ACK, удаляет блоб. ACK — единственный источник правды о доставке: вернувшийся из офлайна узел спрашивает у живых «есть ACK?» и, если есть, просто удаляет своё протухшее.
Самоисцеление — по мотивам Ceph. Здесь я честно взял проверенную модель RADOS и перенёс её на тайники:
| Ceph | Тайники Prizrak |
|---|---|
| CRUSH — детерминированное размещение без центральной таблицы | Rendezvous-хеширование (HRW): любой сам вычисляет, где лежит блоб |
| Cluster map + эпоха | Реестр узлов с версией-эпохой, расходится госсипом |
size / min_size |
RF=4 / min_size=2 |
| Placement Group | Бакет блобов, реплики сверяются по Merkle-корню бакета |
| Primary OSD | Primary-тайник — первый живой в детерминированном порядке |
| Backfill / recovery | Доливка копий до RF при уходе узла |
| Peering при возврате OSD | Merkle-ресинк: удалить доставленное, сбросить лишнее, подтянуть недостающее |
Важная поправка на цензуру: связь «узел↔узел» тоже может быть перерезана. Поэтому базовая гарантия — отправитель сразу кладёт на 4 узла (этот путь заведомо рабочий), самоисцеление — best-effort поверх, а страховка — периодический ре-фан-аут недоставленного отправителем.
Иммутабельность сильно упрощает жизнь по сравнению с Ceph: блобы write-once, удаляются по ACK или TTL (7 дней). Нет мутаций — не нужны версии, строгий порядок записи и кворумы. Только «есть копия / нет копии / есть ACK» плюс anti-entropy.
Закалка. Паддинг длин по вёдрам, PoW-admission против спама, anti-Sybil-диверсити по подписанной метке группы операторов, ротация эпох, jitter. Плюс приватные bridge-узлы, которые не попадают в публичный реестр и раздаются по инвайтам — как мосты Tor.
Проверено. Сеть тайников и федерация через неё покрыты автотестами: 140 зелёных проверок на узле и сервере, включая репликацию, самоисцеление, возврат узла из офлайна и антиэнумерацию листингов.
Обнаружение узлов без «списка в конфиге»
Отдельная проблема, которую пришлось решать: как серверы и узлы находят друг друга, если любой статический список — это подарок цензору? Опубликовал файл со списком узлов — заблокировали все разом.
Принципы, по которым сделано:
- Никаких статических списков. Директория — живое gossip-состояние, а не конфиг.
- Никакой единой точки. Директория — набор подписанных объектов, которые может отдать кто угодно: другой сервер, тайник, зеркало, CDN, DNS. Блокировка одного домена ничего не значит.
- Принцип Керкгоффса. Считаем, что противник знает код и весь публичный каталог. Безопасность — не в секретности списка, а в подписи (нельзя отравить), множестве входов (нельзя заблокировать все) и стелс-транспорте (трафик = обычный HTTPS).
- Достаточно одного живого контакта. Дотянулся до любого узла — развернул весь актуальный каталог. Дальше конфиг не нужен никогда.
Каналы холодного старта, любой одного достаточно: живой госсип от известного пира → вшитые ротируемые сиды → DNS-сиды через DoH (обходит подмену DNS) → подписанный bootstrap-бандл через большой CDN → peer exchange → приватные мосты по инвайтам.
Отдельно про анти-энумерацию: реестр отдаётся порционно и с рейт-лимитом (как BridgeDB у Tor), мосты не госсипятся публично вообще.
Честно: холодный старт под тотальным баном не решается магией — нужен хотя бы один канал получения моста извне (как с Tor bridges). Но слои выше делают так, что до этого почти никто не доходит.
Встроенный VPN: два прыжка и свой протокол
Пользователи всё равно ставят VPN отдельно, так что логично встроить его в клиент — тем более что вся стелс-механика уже есть.

Требования были жёсткие и, на мой взгляд, правильные:
- Никаких стандартных протоколов на проводе. WireGuard, OpenVPN, L2TP, SOCKS в открытую — их сигнатуры фильтры знают давно.
- Два прыжка: клиент → промежуточный узел в стране пользователя → выход за границей. Приманка знает ваш IP, но не знает, куда вы идёте; выход знает назначение, но не знает, кто вы.
- Локальный SOCKS/tun живёт только внутри устройства и на провод не выходит.
- Сообщения и звонки самого мессенджера идут мимо туннеля — иначе получается петля. На Android это
addDisallowedApplicationдля самого себя, на десктопе — маршруты-исключения.
Слои технологии (у каждого свои тесты):
| Слой | Что делает |
|---|---|
| Тень | Шифрование: Noise NK + X25519, ChaCha20-Poly1305, анти-повтор |
| Личина | Узел снаружи — настоящий сайт с «дверью» для своих |
| Эстафета | Два прыжка, слоистое (луковичное) шифрование A/B/C |
| Дыхание | Форма трафика: DATA/PAD/KEEP, keepalive, автопрофиль |
| Стая | Раздача адресов узлов без публичного списка |
| Живучесть | Контроль доступности изнутри сессии, без пинга; автозамена узла |
| Экономика | Билеты, тариф, делёж выручки с операторами |
Три решения, которыми доволен:
- Проверка доступности без пинга. Пинговать узлы снаружи — значит светить их и себя. Живость определяется изнутри уже установленной сессии. Отвалился — переключаемся на узел той же страны, нет — на соседнюю, по схеме make-before-break: сначала поднимается новый выход, потом рвётся старый.
- Страна — по GeoIP наблюдаемого адреса, а не по заявке оператора. Оператор может написать что угодно; проверяем по факту.
- Рейтинг узлов — байесовский, со свежестью 60 дней и антинакруткой, показывается как «4.35 из 5». Ниже 2.5 узел скрывается.

Движок покрыт автотестами — 182 зелёных на пакет VPN. Что осталось до «поля»: реальный TLS-фронт с Let's Encrypt и отпечатком ClientHello под Chrome, реальные сокеты релей↔выход вместо смоделированных в памяти, насос IP-пакетов tun↔движок и полевые проверки на разных операторах. Это я тоже не прячу: движок и слои готовы и покрыты тестами, боевая обвязка — нет.
Мультидевайс без «общего ключа на всех»
Один аккаунт на трёх устройствах — это классическая ловушка. Простое решение «положим один ключ на все устройства» ломает всю модель безопасности: украли планшет — скомпрометирован аккаунт целиком, и отозвать нечего.

Как сделано: два уровня ключей. Корень аккаунта — существующий OpenPGP-ключ (он и так лежит в резервной копии и восстанавливается сид-фразой). У каждого устройства — свой X25519 identity и свои prekeys, подписанные корнем и опубликованные в реестре устройств.
Отправитель берёт список устройств получателя и шифрует сообщение отдельно на каждое устройство отдельной ратчет-сессией. Плюс self-sync: свои исходящие и отметки «прочитано» разъезжаются на собственные устройства.
Что это даёт и чем расплачиваемся:
- Отозвали устройство — оно перестаёт получать конверты, остальные работают.
- Новое устройство видит личную переписку «с этого момента»: старая история не подтягивается. Это следствие forward secrecy, а не недоделка — для групп и каналов вопрос частично закрыт, для лички опциональную E2E-копию истории на сервере я держу в планах.
- Цена: O(участники × устройства) шифрований. Для больших групп это лечится sender-key/MLS — следующий крупный шаг.
Лимит — 10 устройств на аккаунт, при публикации одиннадцатого вытесняется самое старое. Список устройств и кнопка отзыва — в настройках.
Группы, каналы и поиск
Здесь цель была прозаичной: паритет с Telegram, потому что люди не будут пользоваться тем, где нет привычных вещей.
- Типы: публичная / частная группа, канал (пишут только админы).
- Права рядового участника: отправка сообщений, отправка медиа, добавление участников, закрепление, изменение информации о группе, смена собственного тега — шесть отдельных тумблеров плюс персональные исключения для конкретных людей.
- Медленный режим: 5 / 10 / 30 секунд, 1 / 5 / 15 минут, 1 час.
- Автоудаление: выкл / 1 день / 1 неделя / 1 месяц. Сервер удаляет свои копии, а клиенты дополнительно подчищают локальные копии старше срока — иначе «удаление» было бы фикцией.
- История для новых участников — включаемая.
Реестр поиска публичных групп. Отдельный сервис (tech.prizrak.im): серверы публикуют в него только публичные комнаты, записи подписаны ключом домашнего сервера, домен принимается по модели TOFU и сверяется с .well-known, есть рейт-лимит 60 запросов в минуту на IP, лимит 200 групп на домен и TTL 7 дней. Клиент ходит в реестр через свой сервер, так что поиск работает и за стелс-транспортом.
Bot API: как в Telegram, только проще
Ставится на каждый homeserver. Бот — это настоящий аккаунт Призрака, чьи E2E-ключи живут на сервере, а сторонний разработчик работает по обычному HTTP с токеном (наружу такой эндпоинт стоит выставлять только через свой TLS-терминатор):
POST http://<сервер>:8840/bot<TOKEN>/sendMessage
{"chat_id": "!roomid:example.org", "text": "Сборка зелёная ✅"}
Конверт ответа — как в Telegram: {ok: true, result} либо {ok: false, error_code, description}. Методы v0.1: getMe, sendMessage, getUpdates (long-poll до 30 секунд, подтверждение через offset).
Ботов заводит PrizrakFather — прямой аналог BotFather, который поднимается сам при первом старте сервиса. Пишете ему в личку /newbot, отвечаете на пару вопросов, получаете токен. Есть /mybots, /revoke и /deletebot.
Первая целевая задача была приземлённой: постить в каналы из внешних скриптов и сайтов. Тест на 22 проверки гоняет полный цикл: настоящий homeserver + botapi + живой пользователь, диалог с PrizrakFather, отзыв токена, создание группы и пост в неё через API.
Экономика: призраки, и почему деньги централизованы
В мессенджере есть внутренняя валюта — призраки (👻): подарки, донаты, реакции, оплата VPN, награды операторам узлов.
И здесь возникает вопрос, который стоит задавать любому децентрализованному проекту с валютой: что мешает админу чужого сервера начислить себе миллион? Код-то открытый.

Ответ честный и неромантичный: баланс нельзя хранить на самохостящихся серверах. Поэтому граница проведена так:
- Сообщения, группы, каналы, звонки, файлы, ключи — полностью децентрализованы. Центральный сайт для них не нужен вообще.
- Деньги — единый авторитетный реестр («Банк Призраков»). Это неизбежная централизация: за призраки платят настоящими деньгами. Так же устроены Telegram Stars.
Три гаранта:
- Нельзя начислить себе. Баланс живёт только в Банке. Банк увеличивает его исключительно после подтверждённого платежа — webhook с проверкой HMAC-подписи, идемпотентно по идентификатору транзакции. Rogue-админ может только купить призраки за реальные деньги, что вполне легально.
- Нельзя украсть чужие. Каждая операция подписывается отдельным Ed25519-ключом пользователя («ghost-key»), Банк проверяет подпись. У админа домашнего сервера приватного ключа нет. Плюс защита от повтора: метка времени ±5 минут и одноразовый nonce.
- Нельзя угнать чужое имя. Ключ привязывается к
user:domainпо модели TOFU: первый зарегистрировавший задаёт ключ, дальнейшие изменения требуют подписи текущим ключом.
Форк, конечно, может выпустить «свои призраки» — но это будет другая валюта другого банка. Официальные существуют только в официальном Банке, и открытость кода этому не мешает: безопасность держится на секретных платёжных ключах и приватных ключах пользователей, а не на секретности исходников.
Награды операторам. Узел-тайник зарабатывает призраки за аптайм, за доставки и за гигабайт-часы хранения. Доставки пока считаются по самоотчёту узла — криптографический пруф доставки от получателя в планах, и до него владелец сети правит цифры руками. Владелец сети задаёт ставки в админке, видит таблицу операторов и выплачивает накопленное. Аптайм капается на разрыв, выплата идемпотентна.
Из чего это собрано

Монорепозиторий. Ядро на JavaScript переиспользуется десктопом и мобилкой, натив появляется только там, где без него никак (медиа, VPN-сервис Android).
Поднять свой сервер:
cd packages/server
npm install
./deploy/prizrak-deploy.sh init --domain chat.example.org \
--admin root --registration on --relay-url stealth://<IP>:8810
./deploy/prizrak-deploy.sh create-admin --password 'PASSWORD'
./deploy/prizrak-deploy.sh start
Поднять узел-тайник (нужен любой сервер или домашний компьютер с Node.js и постоянным интернетом):
cd packages/deaddrop
npm install
node src/node.js
# статус: http://127.0.0.1:8820/status
Порты по умолчанию: homeserver — 8801, релей звонков — 8810, rendezvous — 8811/UDP, узел-тайник — 8820, реестр групп — 8830, Bot API — 8840. Плюс 80/443 и почтовые, если включена TLS-маскировка. UDP 8811 нужно явно открыть в фаерволе — без этого прямой P2P-путь соберётся только внутри одной Wi-Fi.
Клиенты — на prizrak.im: APK для Android, .exe для Windows, .dmg для macOS, AppImage для Linux. Регистрация — логин и пароль, домен подставляется сам.

Из мелочей, которые дались дороже, чем ожидалось, но без которых продукт не продукт: резервная копия аккаунта в файл, сид-фраза из 17 слов с контрольной суммой (запасной вход и сброс пароля), автообновление в один клик с проверкой подписи Ed25519, сворачивание в трей, звук входящего (да, тот самый ICQ-шный), кружочки-видеосообщения до 60 секунд, голосовые с волной формы сигнала, галочки доставки и прочтения — причём у голосовых и «кружочков» вторая галочка ставится только по факту прослушивания, как в Telegram.
Что не сделано и где слабые места
Раздел, ради которого стоит читать любую статью про безопасность.
- Метаданные. E2E не прячет от вашего homeserver'а сам факт переписки: кто, с кем, когда. Это снижается минимизацией логов и разнесением каналов, но полностью не убирается. Абсолютной невидимости не существует — цель в том, чтобы деанонимизация и блокировка стоили противнику непропорционально дорого.
- Своя криптография в прототипе. Реализация ратчета учебная, хоть и покрыта тестами. Для продакшена нужны зрелые библиотеки и независимый аудит. «Катать свою крипту» — плохая идея, и я это признаю прямо.
- Key transparency — в планах, не в коде.
- MLS — спроектирован, не интегрирован. Сейчас группы работают через per-device fan-out, что корректно, но не масштабируется на очень большие группы.
- VPN — этап «поле» не закрыт: нужен реальный TLS-фронт с автосертификатами, реальные сокеты между релеем и выходом, насос пакетов tun↔движок и проверки на живых сетях.
- Одноразовые prekeys (OTK) переиспользуются сервером: X3DH деградирует к безопасному no-OTK варианту, но для строгой forward secrecy нужны single-use OTK с пополнением. Отдельная задача.
- Видеозвонки на мобильном на паузе после регрессии, ронявшей вместе с видео и аудио. Аудиозвонки работают.
- iOS-клиент ни разу не собирался на устройстве, а нативное видео упирается в отсутствие аппаратного VP8.
- Автовыпуск сертификатов Let's Encrypt пока не автоматизирован — сертификат подкладывается руками.
Ничего из этого не мешает пользоваться мессенджером сегодня, но знать об этом стоит.
Юридическое замечание
Средства обхода блокировок защищают свободу слова и приватность, но их легальность различается по юрисдикциям. Проект развивается как опенсорс для приватности и свободы общения; ответственность за соблюдение применимого к вам закона лежит на вас.
Итог
Prizrak — это попытка сделать честный децентрализованный мессенджер без компромиссов в трёх местах сразу: нет центра, нет привязки к личности, нет узнаваемого следа на проводе. Плюс встроенный VPN, потому что все три задачи опираются на один и тот же стелс-транспорт, и было бы странно не переиспользовать его.
Проект открыт, форки и свои серверы приветствуются: чем больше независимых узлов, тем дороже сеть блокировать.
- Код: https://github.com/Foxeevich/prizrak (AGPL-3.0)
- Сайт и клиенты: https://prizrak.im
- Документация:
docs/ARCHITECTURE.md,docs/VPN-DESIGN.md,docs/GHOST-BANK.mdв репозитории
Буду рад разбору по существу — особенно от тех, кто занимался DPI, транспортами и групповой криптографией. Критика по делу здесь полезнее звёздочек.