Pheme: как пет-проект «отправить пуш с сайта» дорос до федеративного E2E-мессенджера с голосом
- четверг, 30 июля 2026 г. в 00:00:09
Всё началось с обычной бытовой боли. У меня был сайт, и сайту иногда нужно было сказать мне что-то важное: «заказ оплачен», «диск на 91%», «пришёл ответ от банка». Классические варианты доставки этой мысли до моего глаза выглядели так:
Почта. Работает, пока не работает. Письмо уезжает в спам, в промоакции, в «Обновления», в никуда. Ты узнаёшь о падении продакшна через сутки, разгребая папку с рассылками про скидки на матрасы.
Telegram-бот. Прекрасно. До первого «мессенджер недоступен в вашей стране» или очередного раунда игры «угадай, кого блокируют в этом квартале».
SMS. Дорого, и в 2026 году это уже почти ритуальное действие.
Webhook в Slack/Discord. Замечательно, если ты живёшь в Slack. Я не живу.
А ведь есть штука, которая работает у всех, всегда и почти без спроса: push-уведомление в операционную систему. iOS, Android, браузер. Оно приезжает на экран блокировки, оно не зависит от того, открыт ли у тебя таб, и его почти невозможно «не заметить».
Так родилась идея: сервис, куда сайт стучится одним HTTP-запросом, а на всех твоих устройствах через секунду загорается уведомление. Я назвал его Pheme.
А дальше случилось то, что случается со всеми пет-проектами. Ты пишешь «просто релей уведомлений», потом смотришь на код и понимаешь, что у тебя уже есть пользователи, устройства, каналы, история сообщений, лента, комментарии — и остаётся дописать «всего лишь» шифрование, звонки и федерацию, чтобы получился полноценный мессенджер. Что я и сделал.

Сразу оговорюсь, чтобы не разочаровывать в конце: приложения работают, но в магазинах их пока нет, а код честно находится в состоянии альфы. Подробности в разделе «В каком состоянии всё это сейчас» — там же написано, кому я пока не советую на это переезжать.
Ниже — длинный технический разбор того, что получилось: MLS вместо самопального крипто, федерация с нодлистом в духе FidoNet, звонки, где сервер не может подменить DTLS-фингерпринт, расшифровка пуша прямо в iOS-расширении и пара историй про то, как я уронил себе группы на 500 эпох.
Раз уж мы начали со странного названия — закроем вопрос сразу.
Фи́ма. По-английски Pheme читается как FEE-mee (два слога, ударение на первый, «ph» — это обычное «ф»). В классической греческой транслитерации ближе к «Фе́ме», в новогреческой — «Фи́ми». Я говорю «Фима» и не страдаю.
Φήμη — древнегреческая богиня молвы, слухов и известности. У римлян её звали Fama, отсюда наше «фама», «фамильярный» и англоязычное fame. Гесиод описывал её примерно как ту сущность, которая, если один раз о тебе заговорила, уже не замолчит. Вергилий изображал её чудищем, у которого столько же языков и ушей, сколько перьев.
Для сервиса доставки сообщений, которое должно быть быстрым, назойливым и всеслышащим, имя показалось мне отличным. Логотип — наклонённый глиф «вещания» на фиолетово-виноградном градиенте. Больше сакрального смысла нет, извини.
Первая версия была честно скучной и потому надёжной.
Модель. Есть канал. У канала есть публичный channelId и один или несколько API-ключей. Сайт делает:
curl -X POST https://pheme.example/v1/ingest/$CHANNEL_ID/notify \ -H "X-Api-Key: $KEY" \ -H "Idempotency-Key: order-42-paid" \ -d '{"title":"Оплата прошла","body":"Заказ №42, 3400 ₽"}'
И всё. У всех подписанных устройств загорается уведомление, а сообщение навсегда ложится в историю.
Специально не стал делать SDK. Одна ручка, POST, JSON, заголовок с ключом — это интеграция, которая пишется в любом языке за пять строк и не тухнет через два года вместе с зависимостями. На Python:
requests.post( f"https://pheme.example/v1/ingest/{CHANNEL_ID}/notify", headers={"X-Api-Key": KEY, "Idempotency-Key": f"order-{order.id}-paid"}, json={"title": "Оплата прошла", "body": f"Заказ №{order.id}, {order.total} ₽"}, timeout=5, )
На PHP, потому что половина сайтов в мире всё ещё на нём и это нормально:
$ch = curl_init("https://pheme.example/v1/ingest/{$channelId}/notify"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "X-Api-Key: {$key}", "Idempotency-Key: order-{$order->id}-paid", ], CURLOPT_POSTFIELDS => json_encode([ 'title' => 'Оплата прошла', 'body' => "Заказ №{$order->id}, {$order->total} ₽", ], JSON_UNESCAPED_UNICODE), CURLOPT_RETURNTRANSFER => true, ]); curl_exec($ch);
На Node — с картинками, потому что мультипарт тут тоже принимается (до 10 файлов, по 10 МБ):
const form = new FormData() form.append('title', 'Деплой прошёл') form.append('body', `Версия ${version}, ${duration} с`) form.append('images', new Blob([await fs.readFile('graph.png')]), 'graph.png') await fetch(`https://pheme.example/v1/ingest/${channelId}/notify`, { method: 'POST', headers: { 'X-Api-Key': key, 'Idempotency-Key': `deploy-${version}` }, body: form, })
Ну и мой любимый вариант — из шелла в кроне, где вообще нет никакого рантайма:
df -h / | awk 'NR==2 && int($5) > 90 { system("curl -s -X POST https://pheme.example/v1/ingest/'"$CH"'/notify " \ "-H \"X-Api-Key: '"$KEY"'\" " \ "-d \"{\\\"title\\\":\\\"Диск\\\",\\\"body\\\":\\\"" $5 " занято\\\"}\"") }'
Ответ всегда 202 Accepted, потому что дальше всё асинхронно. Сайту не надо ждать, пока пуш доедет до чьего-то телефона, — и это правильно, потому что чужой телефон может быть выключен ещё сутки.

Внутри это три отдельных Go-бинаря на одном модуле и одном наборе internal/-пакетов:
Сервис | Бинарь | Аутентификация | Чем занят |
Ingest API |
| API-ключ канала | принять триггер, отбить лимиты, дедуплицировать, положить в очередь |
App API |
| JWT пользователя | всё остальное: аккаунты, каналы, устройства, история, live-поток |
Dispatcher |
| — | вычитать очередь, записать в базу, разослать пуши, записать квитанции |
Инфраструктура: MongoDB (история + GridFS для картинок), RabbitMQ (durable-очередь и DLQ), Redis (rate limit, идемпотентность, pub/sub), FCM и Web Push для доставки.
Разделение на два домена аутентификации было первым решением, о котором я до сих пор не жалею. Ingest умеет ровно одно — отправить в один конкретный канал. Утёкший ключ из конфига твоего сайта — это неприятно, но это не «кто-то читает твою переписку»: у ключа физически нет ручек для чего-то ещё.
Про «ничего не теряем». Порядок операций тут выбран не случайно:
Ingest отвечает сайту 202 только после того, как RabbitMQ подтвердил публикацию (publisher confirms).
Dispatcher сначала пишет сообщение в Mongo, и только потом пытается слать пуш.
Ack очереди — после записи в базу, а не до.
Не отправившиеся пуши записываются построчно по устройствам, «ядовитые» сообщения уезжают в DLQ.
То есть провал доставки пуша никогда не съедает историю. Уведомление можно не увидеть; сообщение — нельзя потерять.
Идемпотентность заслуживает отдельной строчки, потому что там спрятано маленькое продуктовое решение. Повторный Idempotency-Key в течение суток получает 202 и не кладёт ничего в очередь. Но если Redis, где живут ключи дедупликации, недоступен — запрос принимается без дедупликации. Дубль уведомления это досадно; пропавшее уведомление — это провал сервиса. Я выбрал досадно.
Дальше проект оброс тем, чем обрастает любой такой сервис: веб-мордой на React + Mantine, приложением на Flutter, ролями внутри канала, режимом «по одобрению», обработкой картинок на сервере (декод, поворот по EXIF, ужатие до 1000 px по длинной стороне, пережатие в JPEG q82 — заодно и метаданные с GPS отваливаются), комментариями, админкой.
И вот тут я посмотрел на всё это и подумал: у меня уже есть аккаунты, устройства, каналы, лента, пуши и живой поток. От мессенджера меня отделяет только шифрование. Ну и ещё пара мелочей.
Правильный ответ на вопрос «как сделать E2E в мессенджере» звучит так: не изобретай, возьми стандарт.
Я взял MLS — RFC 9420, Messaging Layer Security. Это IETF-стандарт групповой end-to-end криптографии, вышедший в 2023 году. Если очень грубо: Signal-протокол прекрасен для двоих, но для группы из 30 человек попарные сессии превращаются в комбинаторный ад — каждое сообщение шифруется N раз, а добавление участника трогает N сессий. MLS решает это деревом (TreeKEM): участники — листья бинарного дерева, стоимость добавления или удаления логарифмическая, и у группы есть общий секрет, из которого выводятся ключи.
Ключевые для нас свойства MLS:
Эпохи. Любое изменение состава — это Commit, который двигает группу в новую эпоху и меняет ключи. Вышел человек — он больше не читает будущее (post-compromise security). Вошёл — не читает прошлое (forward secrecy).
Сервер — это Delivery Service, и он недоверенный. В терминах RFC сервер обязан только хранить публичные KeyPackage и передавать непрозрачные байты в порядке. Читать он не может по построению.
Реализация — OpenMLS 0.8 на Rust. Шифросьют:
MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
X25519 для обмена ключами, AES-128-GCM для шифра, SHA-256 для хешей, Ed25519 для подписей. Ничего экзотического, всё скучное и проверенное — ровно то, что нужно.
Вот момент, которым я доволен больше всего с точки зрения архитектуры.
Крипто-ядро живёт в отдельном Rust-крейте crates/pheme-mls. Он собирается как cdylib и попадает в клиенты двумя способами:
Веб — сборка в WASM через wasm-bindgen. Браузер грузит pheme_mls_bg.wasm и дёргает MlsClient.
Мобилка — тот же крейт через FFI, обёрнутый flutter_rust_bridge.
Это значит, что браузер и телефон крутят буквально один и тот же ratchet, скомпилированный из одних и тех же исходников. Не «две совместимые реализации», за которыми надо следить, а один код. Расхождение по протоколу между клиентами физически невозможно, потому что расходиться нечему.
Всё состояние клиента — идентичность плюс ratchet-состояние каждой группы — лежит в одном сериализуемом провайдере. Его можно выгрузить в байты, сохранить (IndexedDB в вебе, secure storage на телефоне) и поднять при следующем запуске. Приватные ключи с устройства не уезжают никогда.
Правило, вокруг которого пришлось перестроить половину клиентской логики, и которое я усвоил через боль:
Два устройства одного человека — это два независимых клиента с разными приватными ключами. Каждое обязано занимать собственный лист, иначе оно просто не расшифрует ни одного сообщения. Пока я этого не понял, чат на втором устройстве выглядел как колонка из «…» — сообщения приходили, но не открывались.
Отсюда три следствия:
группа собирается из одного KeyPackage на каждое устройство каждого участника, а не из одного на человека;
устройство, которого нет в группе, добавляют — группу не сносят и не пересобирают вокруг него, потому что пересборка убивает ключевой материал для всей истории;
удаление человека удаляет все его листья, иначе он спокойно продолжит читать с телефона.
В MLS у каждого листа есть credential — байты, которые говорят, кто это. У меня там:
mimi://<домен>/d/<userId>/<deviceId>
Формат взят из черновиков рабочей группы IETF MIMI (More Instant Messaging Interoperability) — той самой, которая пытается стандартизировать федеративный обмен поверх MLS. Домен в идентификаторе — не украшение: без него alice с одного хоста и alice с другого несут одинаковый credential, и федеративная группа не может их различить.
Строится это одной функцией на Rust, а разбирается обратно другой:
pub fn identity(domain: &str, user_id: &str, device_id: &str) -> Vec<u8> { format!("mimi://{domain}/d/{user_id}/{device_id}").into_bytes() } /// Квалифицированный ПОЛЬЗОВАТЕЛЬ, которому принадлежит credential. /// Это ключ, по которому группируются устройства одного человека — /// для удаления из группы и для сравнения ростера. pub fn user_of(identity: &[u8]) -> Vec<u8> { if let Ok(s) = std::str::from_utf8(identity) { if let Some(rest) = s.strip_prefix("mimi://") { let parts: Vec<&str> = rest.split('/').collect(); if parts.len() == 4 && parts[1] == "d" && !parts[2].is_empty() { return format!("mimi://{}/u/{}", parts[0], parts[2]).into_bytes(); } } } identity.to_vec() // не распарсилось — возвращаем себя целиком }
Обрати внимание на последнюю строку. Credential, который не разобрался в форму устройства, возвращает сам себя, а не пустоту. Иначе клиент постарше или чужой клиент молча резолвился бы в «пользователя с пустым id», и все они оказались бы одним человеком.
Парсинг делает split('/') и требует ровно четыре сегмента. Есть отдельная проверка компонентов, и комментарий к ней я процитирую целиком, потому что он объясняет, почему такие проверки пишут:
id пользователя вида
victim/junkдавал credential, которыйuser_ofрезолвил вmimi://<domain>/u/victim— то есть в другого пользователя, чем credential называет. А это ровно та идентичность, по которой работают удаление из группы и сравнение ростера.
Сейчас id выдаёт сервер, так что дырка недостижима. Но credential теперь кросс-хостовый, а стоимость проверки — три сравнения.
OpenMLS по умолчанию хранит секреты нуля прошлых эпох. Как только применён Commit, всё из предыдущей эпохи становится нерасшифровываемым навсегда.
В чате это не пограничный случай. Это то, что происходит каждый раз, когда кто-то входит или выходит. И это превращает всю ещё не расшифрованную историю в пустые плашки. Плюс сообщения честно приезжают не по порядку: Commit прилетел по SSE и обогнал страницу старых сообщений, пользователь проскроллил вверх — и каждое из них расшифровывается против более старой эпохи.
Ответ RFC — держать окно прошлых эпох. У меня:
const MAX_PAST_EPOCHS: usize = 32;
Цена честная и записана рядом в комментарии: forward secrecy ослаблена ровно на это окно. Устройство, скомпрометированное сейчас, отдаёт ещё и сообщения последних 32 эпох. Это компромисс, а не бесплатный обед, и мне кажется правильным, когда такие вещи лежат в коде явной константой с объяснением, а не размазаны по логике.
Чтобы добавить тебя в группу, кому-то нужен твой публичный KeyPackage. Они лежат на сервере пачками: до 16 КБ на штуку, до 100 за раз.
KeyPackage одноразовый: после использования клиент выбрасывает приватную половину. Отсюда классическая беда — пакеты кончились, и человека невозможно добавить в чат. Решение из RFC — last-resort KeyPackage, многоразовый резервный.
Важная деталь: то, какой пакет является last-resort, решает клиент, а не сервер. Это свойство самих байтов (расширение из RFC 9420), и именно оно заставляет клиента сохранить приватный ключ вместо удаления после первого использования. Флаг, придуманный на сервере, был бы чистой бухгалтерией: пакет всё равно остался бы одноразовым, а пользователя всё равно можно было бы вычерпать досуха.
Обратная сторона того же — зомби-пакеты. Если приватная половина у устройства уже потеряна, а публичный пакет всё ещё лежит на сервере, то добавление человека в группу по такому пакету создаёт лист, который никогда ничего не расшифрует. Как это уронило мне продакшн — ниже, в разделе баек.
Ситуация: ты поставил приложение на новый телефон. В существующей группе твоего листа нет. А добавить собственный лист обычным коммитом устройство не может — это делает только действующий участник.
Варианта было два, и оба плохие:
Ждать, пока кто-то из участников окажется онлайн и впустит тебя. Нормально, если кто-то онлайн. Мёртвое ожидание, если нет.
Пересобрать группу. Разрушительно: работает, только если все остальные быстро мигрируют, а когда не мигрируют — стороны оказываются в разных группах и не читают друг друга. Я на это наступил.
Правильный механизм в RFC 9420 есть, это external commit (§11.2.1), и OpenMLS 0.8.1 умеет его напрямую:
участник экспортирует GroupInfo — подписанное описание текущей эпохи, ratchet-дерева и external_pub;
не-участник скармливает это в join_by_external_commit и производит коммит, который добавляет его собственный лист. Без Welcome. Без чьей-либо помощи.
Два свойства делают это безопасным. Во-первых, недеструктивно: группа не пересобирается, добавляется один лист, все ключи у всех остаются. Если старый «призрачный» лист присоединяющегося ещё висит в дереве, тот же коммит его снимает. Во-вторых, коммит проходит через тот же compare-and-set, что и любой другой: он несёт эпоху, против которой построен, и если чужой коммит успел раньше — сервер отказывает, клиент перечитывает GroupInfo и повторяет. Разъехаться невозможно.
Авторизация тут держится на том, что GroupInfo отдаётся только тем, кто уже в ростере беседы. Собственно, именно поэтому человек вообще может открыть этот чат.
Сервер не читает ничего. Но он — единственная сторона, с которой все согласны, и есть ровно два вопроса, которые больше решить негде: какой группой является эта беседа и чей Commit пришёл первым.
Оба закрываются одной операцией: атомарным compare-and-set по документу группы в Mongo. Ты приносишь коммит и говоришь, на какой эпохе он построен. Если эпоха совпала с текущей — коммит принят, эпоха сдвинута. Если нет — отказ, перечитывай состояние и пересобирай.
Это и есть весь «сервер» в криптографии Pheme. Дальше он передаёт непрозрачные байты.
Safety number (он же контрольное число, он же fingerprint) выводится из ключей, которые реально лежат в группе. Если недоверенный сервер когда-нибудь подсунет чужой ключ — а это и есть подпись атаки «человек посередине» — число изменится.
Сверить его с собеседником по другому каналу — сильная проверка. Но её почти никто никогда не делает, будем честны.
Поэтому число пинится (trust on first use) при первом появлении, и клиент кричит, если оно изменилось. Эта проверка не стоит пользователю вообще ничего и превращает тихую компрометацию в видимую. При этом число легально меняется — когда кого-то добавили или удалили, или человек переустановил приложение и получил новые ключи. Так что изменение это повод переспросить, а не доказательство атаки, и интерфейс говорит ровно это.
Вложения шифруются отдельно от сообщений, потому что MLS-сообщение — не место для мегабайтов.
Конструкция на одну фотографию:
свежий 32-байтный ключ, никогда не переиспользуемый. Две фотографии под одним ключом — это одно совпадение nonce до утечки обеих, а экономить 32 байта незачем;
свежий 12-байтный случайный nonce, приклеенный в начало шифротекста. Так вызывающий код хранит один непрозрачный блоб и не может рассинхронизировать половинки. Nonce не секрет, он просто не должен повторяться;
AES-256-GCM, где в additional data подмешана строка назначения pheme.photo.v1, чтобы блоб нельзя было выдать за что-то другое, запечатанное тем же ключом.
Запечатанные байты уезжают на сервер. А ключ едет внутрь MLS-сообщения, которое эту фотографию упоминает. Сервер держит замок и никогда не получает ключ — они не встречаются нигде, куда он может дотянуться.
Из этого следует деталь, которая мне нравится: сервер не принимает и не хранит content-type вложения. Настоящий тип (image/jpeg, image/png) — свойство открытого текста, и он лежит внутри зашифрованного сообщения рядом с ключом. Принять тип здесь означало бы дать серверу (и его логам) узнать о фотографии то, чего он знать не должен, и соблазнить какой-нибудь будущий хендлер отдать её обратно с этим типом. Так «зашифрованное» хранилище и начинает потихоньку принюхиваться к содержимому.
Ограничение — 10 МБ на шифротекст, то есть на то, что реально приезжает. Клиент должен ужать фото до запечатывания; это заглушка от использования беседы как бесплатного объектного хранилища, а не продуктовая политика.
Новое устройство вошло в группу через external commit и... не видит прошлого. Это корректно с точки зрения MLS и отвратительно с точки зрения человека.
Решение: другое устройство того же пользователя (или другой участник) собирает расшифрованный транскрипт одной беседы, запечатывает его под ключом, выведенным из MLS-группы этой беседы, и кладёт на сервер. Присоединившееся устройство забирает и открывает. Ключа у сервера нет; он видит блоб до 128 МБ и размер.
В блобе только текст и метаданные фотографий, сами фотографии не дублируются — они и так лежат зашифрованными на сервере, и ключи к ним внутри транскрипта.
Самая недооценённая проблема E2E-мессенджера в браузере: iOS Safari выселяет IndexedDB примерно через 7 дней без использования. Твои ключи просто исчезают. Никто ничего не взламывал, ты просто не заходил неделю.
Поэтому экспортированное состояние клиента запечатывается под ключом из парольной фразы (кода восстановления) и хранится на сервере в виде шифротекста. Сервер не видит ни фразы, ни выведенного ключа. Потеря фразы = потеря истории; это осознанная цена zero-knowledge восстановления.
Параметры:
const ARGON_MEM_KIB: u32 = 64 * 1024; // 64 MiB const ARGON_ITERS: u32 = 3; const ARGON_LANES: u32 = 1;
Argon2id для вывода ключа, AES-256-GCM для запечатывания. Аутентифицированный шифр здесь важен: неправильная фраза падает чисто, а не выдаёт мусор.
Цифры заметно выше «пола» OWASP (19 MiB, t=2), и это тоже намеренно. Тот порог откалиброван под серверный логин, который обязан оставаться быстрым под нагрузкой. Здесь ситуация ровно противоположная: запечатанный блоб лежит на сервере, которому мы не доверяем, и атакующий, забравший базу, брутфорсит человеческую парольную фразу полностью офлайн, без лимитов и без ограничения попыток. Бэкап и восстановление — разовые операции по инициативе пользователя. Медленный вывод стоит честному человеку одного мгновения, а атакующему — на каждой из миллиардов попыток.
Выведенный ключ обёрнут в Zeroizing и затирается при уничтожении. Линейная память WASM никогда не возвращается операционной системе, так что забытый ключ живёт столько же, сколько вкладка, и расширяет радиус поражения любого постороннего бага с раскрытием памяти.
Звонки — это WebRTC, то есть медиа идёт напрямую между устройствами и до сервера не доходит. Но чтобы peer-to-peer случился, участники должны обменяться сигналингом: SDP-офферами, ICE-кандидатами. И вот тут кроется ловушка, мимо которой очень легко пройти.
SDP несёт DTLS-фингерпринт. Именно против него аутентифицируется собственное шифрование WebRTC. Сервер, способный переписать SDP, подставляет свой фингерпринт и садится посередине звонка, слушая его целиком. «Медиа шифруется WebRTC» — правда, которая ничего не стоит, если сигналинг не защищён.
Поэтому сигнал в Pheme — это конверт:
{ "h": { "call": "…", "epoch": 7, "device": "…", "seq": 3 }, "n": "<nonce>", "c": "<ciphertext>" }
Заголовок читается сервером и обязан читаться: он говорит, какой это звонок, на какой эпохе выведен ключ, какое устройство отправило и в каком порядке — всё, что получателю нужно до расшифровки, и ничего из этого не выдаёт содержания. Заголовок при этом подмешан в шифротекст как additional authenticated data, так что сервер, переписавший в нём хоть байт, добьётся только того, что тело не откроется — а не тихого перенаправления звонка.
Тело запечатано под ключом, выведенным из MLS-группы беседы. Которого у сервера нет.
Запечатывание в вебе, на WebCrypto:
export async function sealSignal( secret: Uint8Array, header: CallHeader, body: CallBody, ): Promise<string> { const nonce = crypto.getRandomValues(new Uint8Array(12)) const key = await aesKey(secret) const sealed = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce, additionalData: headerAAD(header) }, key, new TextEncoder().encode(JSON.stringify(body)), ) const envelope: CallEnvelope = { h: header, n: bytesToBase64(nonce), c: bytesToBase64(new Uint8Array(sealed)), } return bytesToBase64(new TextEncoder().encode(JSON.stringify(envelope))) }
И ровно то же самое на Dart, потому что телефон должен произвести те же байты:
Future<Uint8List> sealSignal( Uint8List secret, CallHeader header, CallBody body, ) async { final nonce = newNonce(); final sealed = await sealBody( key: secret, nonce: nonce, aad: headerAad(header), plaintext: Uint8List.fromList(utf8.encode(jsonEncode(body.toJson()))), ); return _encodeEnvelope({ 'h': header.toJson(), 'n': base64Encode(nonce), 'c': base64Encode(sealed), }); }
Две функции, два языка, одинаковые байты на выходе. Ниже — почему я не стал сводить их в одну.
Момент, который выглядит как непоследовательность, и он намеренный.
Вся остальная криптография на мобилке идёт через Rust-фасад. А конверт звонка и шифрование фото на Dart, из пакета cryptography. Причина: это единственные два места с требованием побайтовой совместимости между клиентами. Телефон должен позвонить на ноутбук. Если байты разойдутся, отказ будет тихим — шифротекст просто не откроется, и ни у одной стороны не будет в логах ничего, кроме «звонок не установился».
Единственное, что доказывает совпадение, — golden vector, сгенерированный веб-клиентом и проверяемый в тестах. А тест, которому нужна скомпилированная нативная библиотека, не запустится ни на CI-раннере, ни на ноутбуке без NDK. То есть на практике не запустится вообще. Чистый Dart делает так, что он гоняется везде и на каждом коммите.
Общая реализация — один способ быть уверенным, что две стороны согласны. Проверка на каждой сборке — способ получше.
Живая шина событий (SSE) имеет право терять события: у каждого подписчика небольшой буфер, всё, что не влезло, выбрасывается. Для чата это сообщение, которое можно доскроллить. Для звонка это потерянный SDP-ответ, то есть звонок, который молча не соединился.
Поэтому по SSE едет только «дзынь» — «у звонка X есть сигнал номер N» — а сам сигнал клиент забирает из отдельного почтового ящика, где порядок гарантирован и ничего не теряется. Заодно это чинит всё, что было пропущено, пока EventSource переподключался, — сценарий, на который у шины ответа нет вообще.
Ящик живёт в Redis, TTL — две минуты, сиквенсы монотонные внутри звонка, чтобы клиент мог сказать «дай всё после четвёртого» и получить ровно это. Ни один звонок никогда не пишется в базу.
Вторая функция того же ящика — замок первого ответившего:
Claim(ctx, callID, deviceID) (winner string, won bool, err error)
Звонят все устройства, на которых человек залогинен, а победить должно ровно одно. Функция возвращает победителя и признак «это ты» — чтобы проигравший узнал о поражении немедленно и достоверно, а не по каналу, которому разрешено терять сообщения.
Именно в этом весь смысл. Чисто клиентская гонка оставила бы проигравшее устройство звенеть с уже открытым микрофоном до какого-нибудь таймаута, потому что широковещалка «кто-то уже ответил» едет по той же теряющей шине, что и всё остальное. Живой микрофон — не та вещь, которую стоит разыгрывать по каналу с правом на молчаливую потерю пакета.
Для пар, которые не достучались друг до друга напрямую — а это симметричный NAT, то есть большинство мобильных операторов — нужен TURN-релей. Это единственное, что вообще кладёт медиа звонка на нашу машину, и используется меньшинством звонков.
Интересна выдача учётки. Режим use-auth-secret в coturn не держит список пользователей: он принимает любое имя вида <unix-время-истечения>, если пароль равен HMAC-SHA1(secret, username). Значит, короткоживущую учётку можно выпустить прямо в хендлере — без состояния, без похода в coturn, и общий секрет с сервера не уезжает. Утёкшая учётка протухает сама и не разворачивается обратно в секрет.
Весь механизм выдачи — вот эти четыре строки:
// Учётка coturn в режиме use-auth-secret: имя это unix-время истечения, // пароль — HMAC от него под общим секретом. coturn пересчитывает HMAC, // чтобы проверить пароль, и читает имя, чтобы проверить часы. // Он не хранит ничего. Мы тоже. func turnCredential(secret string, expiry time.Time) (username, credential string) { username = strconv.FormatInt(expiry.Unix(), 10) mac := hmac.New(sha1.New, []byte(secret)) mac.Write([]byte(username)) return username, base64.StdEncoding.EncodeToString(mac.Sum(nil)) }
Намеренно: в имени лежит только время истечения. Тот вариант, который показывают в большинстве туториалов, — засунуть туда id пользователя — пишет идентификатор каждого звонящего в логи coturn и не даёт ничего: учётка и так неподделываема и так истекает.
Проблема: сервер шлёт пуш, который сам не может прочитать. Значит, на экране блокировки будет «Новое сообщение» и больше ничего. Полезно примерно никак.
На iOS есть UNNotificationServiceExtension: если в пейлоаде стоит mutable-content, система сначала отдаёт уведомление расширению, и то, что расширение положит в контент, пользователь и увидит.
MLS-состояние приложения лежит в контейнере App Group, куда расширение имеет доступ. Оно берёт шифротекст из пуша, расшифровывает на устройстве и подставляет настоящий текст с именем и аватаркой отправителя.
Тонкость, ради которой пришлось лезть в Rust: расширение не имеет права двигать MLS-состояние. Функция превью открывает копию через PreviewClient — тип, у которого нет export_state и который отказывается применять Commit'ы. То есть подвинуть эпоху он физически не может. Приложение потом прочитает то же сообщение ещё раз, по-настоящему, своим неизрасходованным ключом. «Сообщение расшифровывается ровно один раз» — свойство копии, и на это стоят отдельные тесты.
Схематично весь Swift-файл выглядит так:
final class NotificationService: UNNotificationServiceExtension { /// Охраняет то, что должно случиться ровно один раз. /// /// Завершить расширение могут два пути — догрузка аватарки и вызов /// serviceExtensionTimeWillExpire по истечении бюджета — и они ГОНЯЮТСЯ. /// Двойной вызов content handler это UB; замок делает победителем того, /// кто пришёл первым. private let lock = NSLock() private func deliver(_ content: UNNotificationContent) { lock.lock() let handler = contentHandler contentHandler = nil lock.unlock() handler?(content) } }
Правило, под которым живёт весь файл: каждый путь заканчивается уведомлением. Провал показывает generic-текст от сервера, но никогда не пустоту и никогда не ошибку. iOS даёт расширению около 30 секунд, потом вызывает serviceExtensionTimeWillExpire и показывает то, что есть на этот момент. Поэтому fallback готовится первым и перезаписывается только при успехе.
И ещё: два пути могут завершить расширение — догрузка аватарки и истечение бюджета — и они гонятся. Двойной вызов content handler — неопределённое поведение, поэтому там NSLock, и считается тот, кто пришёл первым.
Отдельно — приватность превью. Что именно разрешено показать в уведомлении, решает получатель, а не отправитель. Поэтому фан-аут не может собрать один пейлоад и разослать его всем: два участника одной беседы с разными настройками должны получить каждый своё. Ошибка тут не косметическая — любой вариант «одного общего пейлоада» либо игнорирует явную просьбу пользователя о приватности, либо срезает имя у того, кто об этом не просил. Тест на это существует раньше, чем код, который партиционирует рассылку.
Теперь главное. То, ради чего половина всего вышеописанного вообще имеет смысл.
Self-hosting означает: ты берёшь исходники, поднимаешь их на своём домене, и это твой мессенджер. Твоя база, твои пользователи, твои ключи, твои правила. Никакого «главного сервера Pheme», от которого ты зависишь.
Звучит красиво ровно до вопроса «а с кем я там буду переписываться?». Ответ на него — федерация: пользователи разных инстансов общаются между собой, не отдавая при этом свойство, ради которого всё затевалось. Сервер не читает сообщения. И чужой сервер тоже не читает.
Я был фидошником. И структура FidoNet — сеть независимых узлов, связанных не общим сервером, а общим нодлистом, — вернулась ко мне ровно тогда, когда понадобилось решать, как хосты узнают друг о друге.
Тот же приём здесь:
// Package nodelist is the signed map of who is in the federated network. type Node struct { Domain string PublicKey ed25519.PublicKey Alias string } type List struct { Serial uint64 Issued time.Time Expires time.Time Nodes []Node }
Координатор собирает и подписывает документ «домен → публичный ключ». Каждый хост зеркалит один и тот же документ. Каждый хост настроен на публичный ключ координатора, поэтому список доверенный потому, что координатор его подписал, а не потому, что скачан откуда-то оттуда.
Serial растёт с каждым выпуском — чтобы хост мог отказаться заменить новый список старым. Откат списка означал бы возврат в сеть удалённого узла, и это ровно тот механизм, которым делается отзыв: скомпрометированный хост убирается выпуском нового списка без него. Поэтому же у списка есть срок годности.
Alias — короткое сетевое имя хоста. Оно ничего не аутентифицирует (за это отвечают домен и ключ), это просто более человеческое написание, о котором вся сеть договорилась, потому что оно подписано в том же документе. Благодаря ему собеседник адресуется как name@pheme1, а не name@очень-длинный-хост.example.tld.
Админка координатора — это roster.json, обычный diff-абельный JSON:
go run ./api/cmd/nodelist init # один раз, создаёт coordinator.key go run ./api/cmd/nodelist pubkey # значение, которое раздаётся всем хостам go run ./api/cmd/nodelist add talk.example.com <их-ключ> # приём в сеть go run ./api/cmd/nodelist add talk.example.com <новый> # повторное добавление = ротация go run ./api/cmd/nodelist remove talk.example.com # вот так выглядит отзыв
Свой ключ хоста присоединяющийся оператор генерирует симметричной командой и отправляет координатору только публичную половину:
cd api && go run ./cmd/hostkey -env
Приём и исключение узла — рецензируемые изменения файла. Как в фидошном нодлисте, только Ed25519 вместо доверия к тому, что ты скачал правильный nodelist.zip.
Одна деталь поведения, на которую я потратил отдельный подход: настроенный на федерацию сервер отказывается стартовать, если список отсутствует, побит, просрочен или подписан не тем ключом. Не «тихо становится одиночным» — падает. Молчаливая деградация в безопасности это способ однажды обнаружить, что ты полгода работал не в той модели угроз, в которой думал.
Компромисс тут настоящий, и я не хочу его заминать: приём в сеть централизован, хотя хостинг — нет. Это разные вещи, и первое я выбрал сознательно. Что оно покупает — рабочий ответ на спам и злоупотребления с первого дня. У открытой федерации такого ответа нет, что мы все прекрасно наблюдаем.
Рабочая группа IETF MIMI существует ровно под эту задачу — федеративный обмен поверх MLS — и форма у неё правильная. Но пользоваться ей как спецификацией сегодня нельзя.
По состоянию на июль 2026:
Документ | Ревизия | Статус |
| 03 | документ рабочей группы |
| 06 | документ рабочей группы |
| 09 | самый зрелый |
| 04 | документ рабочей группы |
Ни один не RFC. Ни назначенного шеперда, ни запланированной оценки IESG. В разделе про credential протокольного черновика до сих пор стоит буквальный TODO: What types of credential are required / allowed?.
Поэтому решение такое: следовать форме MIMI (хаб на комнату, идентификаторы с доменом, HTTPS между провайдерами), держать собственные выборы сменными там, где MIMI ещё не решила, и не блокироваться на её взрослении. Там, где черновик молчит — а он молчит о многом, — решение наше и записано в наших же документах.
Полной открытой реализации MIMI не существует. Ближайшее — [phnx-im/air](https://github.com/phnx-im/air) от двух авторов черновика, поверх OpenMLS, который мы и так используем.
А вот тут интереснее.
В MIMI у комнаты есть один хаб — провайдер, который упорядочивает события. Остальные провайдеры — фолловеры, они проксируют запросы своих пользователей к хабу. Эту часть я взял, она ложится ровно на то, что код и так делает: compare-and-set по одному документу и есть хаб, просто пока без соседей.
Но в текущем черновике протокола нет никакой целостности порядка. FanoutMessage несёт часы отправителя и больше ничего: ни порядкового номера, ни хеша предыдущего сообщения, ни transcript hash. Фолловеры верят хабу на слово, опираясь только на продвижение эпох самого MLS.
Это регресс относительно Linearized Matrix, предка MIMI, где провайдеры «прикрепляли к каждому событию проверяемые хеши и подписи как защиту от изменения событий хаб-сервером».
Принять это я не могу. Всё предложение Pheme стоит на том, что сервер недоверенный. Сейчас это сохраняется: сервер релеит шифротекст, который не может открыть. По MIMI-как-написано чужой хаб — которым рулит человек, которого пользователь в глаза не видел — мог бы переупорядочить, выбросить или выборочно доставить сообщения одним участникам и не доставить другим, и внутри протокола этого не обнаружить никак. MLS даёт конфиденциальность и согласие об эпохе. Целостности доставки он не даёт.
Поэтому: каждое сообщение фан-аута несёт порядковый номер и хеш предыдущего, подписанные хабом.
const scheme = "pheme-mlschain-v1" // Link считает хеш звена для коммита на позиции seq, следующего за prevHash. func Link(prevHash []byte, seq int64, groupID string, commit []byte) []byte { h := sha256.New() // Метка контекста первой: этими байтами не должно оказаться возможным // подменить что-либо другое, что подписывает тот же ключ хоста. writeField(h, []byte(scheme)) writeField(h, prevHash) var s [8]byte binary.BigEndian.PutUint64(s[:], uint64(seq)) h.Write(s[:]) // фиксированная ширина, длина не нужна writeField(h, []byte(groupID)) writeField(h, commit) return h.Sum(nil) } // writeField пишет поле с префиксом длины, чтобы конкатенация была однозначной: // ("ab","c") и ("a","bc") должны хешироваться по-разному.
Хеш связывает позицию коммита (seq, prevHash) с его содержимым (шифротекст) и с группой. Поскольку это чистая функция от входов, хаб и каждый фолловер вычисляют одно и то же значение независимо: фолловер, у которого от собственного prevHash получился другой хеш, поймал хаб на переупорядочивании, выбросе или форке лога.
Плюс Ed25519-подпись хоста поверх этого. seq — это эпоха, которую коммит порождает, так что ничего нового не пересчитывается. Хранилище продлевает цепочку внутри того же атомарного CAS, что и сдвиг эпохи: prevHash читается и новая голова пишется под одним замком, поэтому цепочка не может обогнать порядок, который она удостоверяет.
Фолловер пересчитывает хеш от собственной головы, сравнивает с хабовым и проверяет подпись, прежде чем продвинуться. Несовпадение — переупорядочивание, выброс, форк — или подпись не от хаба означает отказ, и зеркало не двигается. Есть тесты TestSignedOrderingChainConvergesAcrossHosts и TestMirrorRefusesTamperedOrderingLink.
Это то, что делала Linearized Matrix, это немного кода, и это сохраняет истинным утверждение «сервер твоего друга не может соврать тебе о том, что было сказано».
Про пробел в черновике правильно написать в mimi@ietf.org, а не только чинить у себя. Скорее всего его закроют до RFC, и быть имплементатором с конкретным дизайном — полезный способ поднять вопрос.
Хост объявляет о себе двумя путями:
GET /.well-known/pheme-federation /federation/v1/*
Документ обнаружения перечисляет реализованные эндпоинты. Запросы едут по HTTPS и подписываются ключом хоста-отправителя. Подпись связывает метод, путь, origin, назначение, id ключа, время, nonce и хеш тела — то есть принимающая сторона отбивает не только чужого, но и переигранного, просроченного, отправленного не по адресу и подменённого в теле. Origin ищется в проверенном нодлисте; неизвестный — отказ.
MIMI, кстати, отказалась от матриксовых подписей запросов в пользу одного взаимного TLS. Мне это не подошло: ключ подписи хоста у нас уже есть — он нужен для нодлиста и для цепочки порядка, — а подпись на прикладном уровне не зависит от того, что TLS терминирует именно приложение. Это важно ровно тогда, когда половина смысла проекта в том, что хосты стоят за CDN и обратными прокси.
Отдельная тонкость: федеративные пути намеренно не прячутся за приватным префиксом клиентского API. Peer его просто не может узнать. А standalone-хост эти пути вообще не проксирует — и nginx отдаёт на них ту же страницу-прикрытие, что и на любой другой неизвестный URL.
Отдельная история — F4. Handshake-коммиты теперь PublicMessage, а не приватные. Это позволило написать примерно 200-строчный Go-декодер без единой зависимости (internal/mlswire), который вычитывает эпоху, на которой построен Commit. И теперь сервер отказывает коммиту, у которого заявленная baseEpoch расходится с распарсенной, — ровно та ложь, которую старая обёртка поймать не могла. Оппортунистически: непрозрачный PrivateMessage по-прежнему проходит по заявленному значению, так что раскатка не требует дня X.
Чего там нет и почему честно: разбор Remove-предложений для проверки «удалять может только админ» требует серверной карты «лист → пользователь», а для настоящей аутентичности — GroupContext-хешей, которых у бесстатусного сервера нет (RFC 9420 §6). Полная аутентичность коммита — по построению работа хаба.
Первым поехало то, у чего нет группового состояния: федеративные каналы. У широковещания нет эпохи и нет авторитета порядка. Пользователь одного хоста подписывается на открытый канал другого; origin запоминает хост-подписчика и на каждый новый пост шлёт ему доставку, где зеркало сохраняет пост и раздаёт локальным подписчикам.
Картинки в постах ездят отдельным приключением: origin отдаёт их по своему нескрытому-но-неизвестному префиксу, до которого peer достучаться не может, поэтому обработанные байты едут инлайном по S2S-транспорту, а подписчик перекладывает их в собственное блоб-хранилище. Best-effort: если картинка не доехала, текст всё равно приходит.
Потом — беседы. Хаб — хост создателя. Фолловер собирает коммит локально и постит своему хосту, тот форвардит хабу беседы, хаб прогоняет проверку эпохи и CAS, после чего раздаёт принятый коммит всем хостам-участникам.
Проверено вживую 21 июля 2026. Два раздельно развёрнутых хоста, каждый — полный стек с собственной Mongo, общий подписанный нодлист, S2S по Ed25519 поверх TLS. alice@hub добавляет mimi://<второй-хост>/u/<bob>, на фолловере поднимается зеркало, пост alice релеится в зеркало, пост bob из зеркала форвардится и упорядочивается хабом, оба хоста сходятся на одном упорядоченном логе.
Отдельно потребовалась двухбраузерная проверка. Сервис доставки — это Go-сервер, в котором нет MLS вообще, поэтому половину про расшифровку кросс-хостового round-trip можно доказать только двумя настоящими клиентами на двух серверах. Половину про порядок — что хаб корректно упорядочивает и релеит непрозрачный шифротекст, а зеркало форвардит и применяет — доказывают in-process тесты через два реальных подписанных хендлера.
Звонки федеративные тоже. У звонка нет хаба: каждый хост-участник релеит запечатанные сигналы своих участников всем остальным хостам, где они ложатся в собственный почтовый ящик этого хоста. Каждый хост держит полную упорядоченную копию, каждое устройство читает у себя дома. Замок первого ответившего намеренно остаётся локальным: все устройства человека живут на его домашнем хосте, и это ровно то множество, которое замок и разыгрывает.
TURN тоже общий. Когда клиент запрашивает ICE для кросс-хостовой беседы, его хост добавляет TURN каждого удалённого хоста-участника, каждый — выпущенный этим хостом из собственного секрета и полученный по подписанному транспорту. Оба пира держат оба набора, ICE находит общий релей, а секрет не покидает свой хост — уезжает только короткоживущая учётка.
Раздел, который я считаю обязательным в любом тексте про федерацию, потому что его обычно нет.
Пуши. FCM и APNs доставляют в операционную систему. Федеративная сеть никак не меняет того, кто из них до тебя дотягивается. Если у тебя на телефоне нет сервисов Google — федерация тебе тут не поможет.
Метаданные. Хаб видит, кто с кем и когда разговаривает, ровно как локальный сервер видит это сегодня. Кросс-хостово это теперь видно ещё и чужому оператору. Это настоящее снижение приватности по сравнению с одиночным инстансом, и пользователям надо говорить об этом прямо.
Доступность. Комната доступна ровно настолько, насколько доступен её хаб. Если хаб временно недоступен, история читается с любого зеркала, но новые события встают. Фолловеры не принимают записи самостоятельно — сеть выбирает консистентность и отказывается от split brain. Мне это кажется единственным честным выбором для мессенджера: два разошедшихся «правильных» лога переписки хуже, чем честная пауза. Идентификатор комнаты содержит домен хаба, поэтому переезд меняет идентификатор — вероятно, именно поэтому MIMI и оставила миграцию открытой, а не забыла про неё. В v1 комната, чей хаб исчез, перестаёт принимать новые события; её история остаётся читаемой у всех участников, потому что она у всех есть.
Инстанс поднимается одной командой:
cd deploy/self-host ./setup.sh
Скрипт спросит имя хоста и админский email, сгенерирует все секреты, отрендерит vhost для nginx и напечатает URL, который можно раздавать пользователям. Потом:
./verify.sh https://talk.example.com <path-prefix>
verify.sh надо запускать не с сервера и перегонять после каждого изменения nginx.
Дальше начинается то, что мне кажется самой недооценённой частью всей затеи.
Одно развёртывание на одном домене — это одно правило файрвола до состояния «его больше нет». Много инстансов на многих доменах, у каждого свой сертификат и своя маскировка, не имеют общего отпечатка и общей точки отказа. В этом весь смысл: твой инстанс — не зеркало чужого, он сам по себе.
Поэтому инстанс собран так, чтобы не объявлять о себе. API живёт по неанонсированному префиксу пути, а всё остальное на хосте — обычный статический сайт. Сканер, метущий адресное пространство, находит небольшой сайтик и едет дальше.
При этом:
Префикс — не пароль. Каждая ручка за ним аутентифицируется ровно так же, как аутентифицировалась бы без него. Префикс лежит в URL каждого запроса, так что любой CDN или middlebox, терминирующий TLS перед тобой, его видит. Он неанонсированный, что ломает автоматическое обнаружение, — а не секретный, что было бы совсем другим заявлением.
И отдельная просьба от setup.sh, которую он вкручивает в verify.sh как проваливающуюся проверку: смени сайт-прикрытие перед запуском в прод. Три-четыре сайта, которые едут в комплекте с Pheme и отданы дословно, — это отпечаток, а не маскировка; узнать их будет проще, чем узнать сам Pheme. Не обязательно делать что-то сложное. Страница про твоё реальное увлечение, бизнес, клуб — что угодно, у чего есть правдоподобная причина существовать на этом домене.
Почта по умолчанию (PHEME_MAIL_DRIVER=log) печатает коды подтверждения в лог контейнера вместо отправки:
docker compose --env-file node.env logs app | grep -i code
Этого хватает, чтобы зарегистрировать себя и пару друзей. Для чего-то большего нужен SMTP-релей — и надо понимать, что письма со свежего домена будут падать в спам, пока не встанут SPF, DKIM и DMARC.
И одно обязательство, о котором лучше узнать здесь, чем от чьего-нибудь юриста. Pheme под GPLv3, а веб-клиент раздаётся каждому браузеру, который открывает твой инстанс. Значит, если ты правил код — ты его распространяешь, и обязан отдать исходники своей версии тем, кому раздал ссылку. Поднял как есть, ничего не менял — просто оставь ссылку на репозиторий. Подробнее в разделе про лицензию ниже, там же объяснено, почему это не бюрократия, а прямое следствие всего, ради чего проект писался.
Сейчас в проекте четыре кодовые базы:
Часть | Язык | Строк |
API и воркеры | Go | ~48 700 |
Веб | TypeScript + React 19 + Mantine 9, Vite | ~22 200 |
Мобилка | Dart + Flutter | ~29 000 |
Крипто-ядро | Rust (OpenMLS) | ~3 600 |
Меня об этом спрашивают чаще, чем про MLS, так что отвечу подробно.

Причина первая, главная: крипто-ядро уже на Rust. Если бы я писал два нативных приложения, мне понадобился бы Rust-мост под Kotlin и Rust-мост под Swift, две обвязки поверх одного и того же FFI и два места, где эти обвязки могут разъехаться. С Flutter мост один: flutter_rust_bridge над тем же самым крейтом pheme-mls. Мобилка крутит тот же ratchet, что и веб через WASM. Не «совместимые реализации» — один код.
Причина вторая: соотношение объёма к количеству рук. Рук у меня две. 29 тысяч строк Dart против двух отдельных приложений тысяч по двадцать каждое — это разница между «проект живёт» и «проект существует в виде README».
Причина третья: WebRTC и всё остальное там уже есть. flutter_webrtc, firebase_messaging, flutter_secure_storage, mobile_scanner для QR — экосистема закрывает ровно те платформенные штуки, которые мне нужны, и закрывает нормально.
Чего Flutter не покрывает и где пришлось писать нативно. iOS-расширение уведомлений — Swift, потому что это отдельный процесс со своим бюджетом времени, и Flutter там неуместен по определению. Доступ к контейнеру App Group берётся напрямую через path_provider_foundation, потому что сам path_provider его не отдаёт, а MLS-состояние должно лежать там, где до него дотянется расширение. Про NDK и cargo-ndk для Android-сборки я уже написал выше.
Про «Flutter выглядит не по-родному». Приложение сейчас в едином стеклянном оформлении на обеих платформах — я сознательно выбрал собственный визуальный язык вместо попытки притвориться то Material, то Cupertino. Притворяться получается плохо, а последовательный собственный вид получается хорошо.
Раздел, ради которого, подозреваю, половина читателей сюда доскроллила.
В июле 2026 года два клиента устроили друг другу войну примирений. Каждый смотрел на группу, видел, что состав устройств не тот, который он считает правильным, и выпускал коммит. Который порождал новую эпоху. На которую второй клиент реагировал точно так же.
Два коммита в секунду. Пятьсот эпох за двое суток. И ни одной строчки в логах сервера, пока это происходило, потому что каждый отдельный коммит абсолютно легитимен.
Теперь такая строчка есть:
const ( stormAlarmWindow = 10 * time.Minute stormAlarmThreshold = 40 )
Детектор ничего не запрещает — коммит, который ему не нравится, вполне может оказаться тем самым, который группу и починит. Он делает шторм невозможным не заметить. Честная churn-нагрузка маленькая: создать группу — один коммит, впустить новое устройство или выкинуть мёртвое — ещё один-два, и случается это изредка.
Корень же был в тех самых зомби-KeyPackage: клиент добавлял в группу лист по пакету, приватную половину которого владелец давно потерял, лист ничего не расшифровывал, клиент видел «неправильное» состояние и шёл примирять заново. Классический цикл, где каждый шаг локально корректен.
Побочный эффект этой истории — почему я так долго не менял формат credential. Смешивание форматов credential внутри одной группы это задокументированный способ выстрелить себе в ногу, и user_of со своим legacy-fallback существует именно оттуда. Переход на доменные mimi://-credential был отложен до момента, когда его можно было сделать чистым разрывом, без групп со смешанным форматом.
Когда семафор отправки пушей забит, уведомление отбрасывается. Раньше каждый отброс писал собственную строку WARN.
В тихом логе это выглядит прекрасно и разваливается ровно тогда, когда становится важно. Один замер на потолке дал 456 строк за 45 секунд. Оператор, который ищет причину плохого деплоя, листает сотни копий одного предложения, а любое другое предупреждение из этого окна под ними похоронено.
Хуже того, объём не сообщает ничего полезного. Оператору нужно не «вот отброс» повторённое N раз, а сколько и за какое время — число, по которому понятно, потолок задевают или на нём сидят.
Теперь первый отброс репортится немедленно (переход из «работает» в «отбрасывает» — само по себе событие, которое стоит увидеть), а дальше идёт сводка раз в 30 секунд, пока это продолжается. Счётчик умеет считать пачками, потому что один фан-аут может провалиться сразу для многих устройств, и поштучный подсчёт врал бы на размер беседы.
Уже упоминал, но повторю как отдельную байку, потому что это была самая долгая отладка в проекте. Второе устройство показывало ленту, где вместо каждого сообщения стояло «…». Сообщения приходили. Метаданные были на месте. Расшифровка молча не удавалась.
Причина: я строил группу из одного KeyPackage на пользователя. Второе устройство того же человека не имело листа в дереве и, соответственно, не имело ключей. Всё честно работало по протоколу и было полностью бесполезно для человека.
Фикс — тот самый комментарий на пол-экрана в web/src/lib/mls.ts, который начинается с «AN MLS LEAF IS A DEVICE, NOT A PERSON» капслоком. Иногда капслок в комментарии — это не крик, а закладка для себя из будущего.
const ( stormAlarmWindow = 10 * time.Minute stormAlarmThreshold = 40 )
Раздел, который в статьях про пет-проекты обычно пишут в жанре «мы готовы к вашим миллионам пользователей». Я напишу как есть.
Что работает. Серверная часть, веб-клиент, приложения под Android и iOS. Шифрованные личные переписки и группы, голосовые звонки один на один, каналы с пушами, вложения, звонки и подписки на каналы через границу хостов. Федерация проверена вживую на двух отдельно развёрнутых инстансах, а не только в тестах.
Чего нет. Приложений нет в App Store и Google Play. Они собираются и ставятся, я хожу с ними каждый день, но публикации ещё не было: у любого мессенджера ревью — отдельный квест, а у мессенджера, который просит указать адрес своего сервера при входе, квест с сюжетными поворотами. Пока установка руками из сборки.
Код в состоянии альфы, и это не кокетство. Что это означает практически:
Формат данных ещё может поехать. Я уже делал «чистые разрывы» — например, когда квитанции о прочтении переезжали с временных меток на порядковые номера, это был одномоментный слом сервера, веба и мобилки сразу. Такое может повториться.
Аудита не было. Криптография собрана из правильных кирпичей (RFC 9420, OpenMLS, стандартные примитивы), но «использует проверенные компоненты» и «проверено целиком» — разные утверждения, и второго я делать не буду.
Ошибки в местах, куда не ходит E2E-набор, ещё водятся. 113 тестовых файлов на Go-стороне и двухбраузерная E2E-проверка кросс-хостового шифрования ловят много, но не всё.
API v1 пока правильнее читать как v0.
То есть: поднять себе инстанс и позвать друзей — отличная идея, я буду рад. Переводить на него команду, для которой утечка переписки означает настоящие последствия, — рано. Скажу об этом прямо, потому что альтернатива — чтобы кто-то это выяснил сам и в неподходящий момент.
Открытых вопросов много:
Каналы в режиме «по одобрению» не федерируются: очередь одобрений пришлось бы научить моделировать удалённый хост и удалённого пользователя, а широковещание из заголовка, текста и картинок этого пока не требует.
Комментарии к зеркальному посту не федерируются — зеркало пока read-only.
Миграция хаба не решена, и я скорее напишу об этом в пользовательской документации, чем сделаю вид, что вопроса нет. Комната, чей хаб потерян навсегда, замерзает в read-only, с ручным аварийным переездом и без автоматической миграции в v1.
SemiPrivateMessage из MIMI (течёт меньше, чем PublicMessage) — цель, но зависит от более молодого черновика, который OpenMLS 0.8 может и не реализовать.
Групповые звонки. Пока только один на один.
Магазины приложений. См. выше.

Меня зовут Михаил Матвеев, живу в Салониках. Семнадцать с лишним лет в Deutsche Telekom и T-Systems, и путь там был довольно извилистый: начинал разработчиком на Remedy ARS, писал внутренние Perl-скрипты для вики с документацией, потом ушёл в тестирование, оттуда в системную архитектуру (SOAP, WSDL, вот это всё), потом Product Owner хаба тест-автоматизации, потом Test Manager, потом People Lead — сначала у QA, потом у двух юнитов разработки. Сейчас TPM, но, кажется, снова ухожу в разработку. То есть последние годы я по должности занимаюсь бэклогом, наймом, бюджетами и перформанс-ревью. Что, если честно, и есть главная причина существования Pheme: когда твой рабочий день состоит из приоритизации чужих задач, очень хочется вечером открыть редактор и подраться с ratchet-деревом, где никто не спорит о сроках, а есть только «расшифровалось» и «не расшифровалось».
Из другого, что я делаю в свободное время:
FRANK — открытая железка вокруг того же RP2350, под которую всё это писалось. Платы развожу в KiCad, корпуса рисую в Fusion 360, каталог прошивок перевалил за двадцать штук: NES, SNES, Sega Genesis, IBM PC/i386, DOOM и далее по списку.
FRANK OS — десктопная операционка для микроконтроллера RP2350. Оконный менеджер в стиле Windows 95, терминал, файловый менеджер и 14 встроенных приложений, всё это в 520 КБ SRAM. Поверх FreeRTOS, с выводом видео по DVI, вводом с PS/2 и хранением на SD-карте; сторонние ELF-приложения грузятся через стабильную таблицу системных вызовов. Больше 230 звёзд, мой самый популярный опенсорс.
Heretic — фреймворк для сайтов с server-side rendering: Marko.js на UI, Fastify на сервере, MongoDB и Redis.
«1000» — карточная игра под Android на Java и AndEngine, больше пяти миллионов установок в Google Play. Написана давно, но это до сих пор самая используемая программа, которую я когда-либо делал, и с этим фактом я живу.
Диапазон от Perl-скриптов и ассемблерных плясок на RP2350 до MLS и федерации выглядит хаотично, и он такой и есть. Зато очень пригождается: половина решений в Pheme (например, привычка считать, что канал связи имеет право терять сообщения, и что от этого надо защищаться явно) приехала не из чтения RFC, а из фидошного прошлого и лет, проведённых в тестировании чужих распределённых систем.
Найти меня: https://rh1.tech, https://github.com/rh1tech, Telegram.
Раз уж весь текст про то, что серверу нельзя доверять, — про лицензию надо сказать конкретно, а не отделаться словом «опенсорс».
Репозиторий целиком под GNU GPL v3, версия 3 only. Не «or later», не MIT, не «исходники открыты, смотрите на здоровье».
Выбор осознанный и прямо вытекает из смысла проекта. Утверждение «сервер не может прочитать твои сообщения» проверяемо ровно настолько, насколько ты можешь посмотреть на код того сервера, который тебе эти сообщения отдаёт. С пермиссивной лицензией совершенно легален сценарий, где кто-то берёт Pheme, ставит его на своём домене, дописывает пару строк в обработчик и раздаёт это людям как «тот самый безопасный мессенджер» — а исходники его сборки не показывает никому. Формально всё чисто. Фактически пользователь получил ровно ту штуку, от которой всё это затевалось уходить.
GPL закрывает это тем, что обязательства возникают при распространении. А веб-клиент распространяется каждому браузеру, который открывает приложение. То есть человек, который поднял себе инстанс и раздал ссылку, уже распространяет — и обязан отдать исходники своей версии тем, кому раздал. Для мессенджера это не идеологическая поза, а единственный способ сделать обещание про недоверенный сервер проверяемым дальше, чем на один хоп.
Отдельно есть коммерческая лицензия для тех, кто хочет строить на этом что-то закрытое. Работает она честно и с ограничением, которое я записал в COMMERCIAL-LICENSE.md сам на себя: коммерческие условия можно давать только на тот код, права на который у меня есть, и никакое соглашение не выдаёт прав на чужой код. Прямо сейчас это означает, что веб-клиент под коммерческую лицензию целиком отдать нельзя, и вот почему.
Часть поведения чата в вебе — производная от Telegram Web K, который распространяется под GPLv3. Это записано в web/NOTICE.md поимённо, с таблицей «что здесь → откуда взято»:
useChatScroll.ts — удержание позиции прокрутки по расстоянию от низа при подгрузке старых сообщений и допуск «мы всё ещё внизу», из scrollSaver.ts;
открытие канала на первом непрочитанном, а не на самом свежем, вместе с правилом «ровно одно непрочитанное → всё-таки вниз», из bubbles.ts;
UnreadDivider.tsx — разделитель непрочитанного, который показывается над первым непрочитанным и только если оно не самое новое;
MediaViewer.tsx — модель взаимодействия полноэкранного просмотрщика: Escape, стрелки, свайпы, утягивание вниз для закрытия, зум.
Это не дословные копии — вокруг другая архитектура, React с Mantine вместо Solid.js, — но это производные работы, и они так и оформлены. Строчку «© Eduard Kuzmenko и контрибьюторы Telegram Web K» я написал в тот же день, когда написал производный код, а не когда-нибудь потом.
Скажу почему считаю это важным. Поведение прокрутки в чате — та вещь, которую все считают тривиальной, пока не сядут писать. Правильно удержать позицию при подгрузке истории, не дёрнуть ленту, открыть на нужном месте, не сломать всё это на инерционном скролле в Safari — это годы чужой полировки. Я честно посмотрел, как это сделано у людей, которые уже потратили на это годы, и сделал похоже. Прятать такое за формулировкой «вдохновлялись» некрасиво, а с GPL ещё и незаконно.
Важно: api/ с кодом Telegram не пересекается никак.
Код целиком: https://github.com/rh1tech/pheme
api/ — три Go-сервиса на одном модуле
crates/pheme-mls/ — крипто-ядро на OpenMLS, из которого собираются и WASM, и мобильный FFI
web/ — React + Mantine SPA
mobile/ — Flutter под iOS и Android
deploy/self-host/ — setup.sh, который поднимает инстанс
docs/federation.md — как войти в сеть и как поднять координатора
docs/development/federation.md — те самые решения с обоснованиями, включая спор с черновиком MIMI
docs/protocol.md — формат S2S-запросов и подписей
LICENSE, COMMERCIAL-LICENSE.md, web/NOTICE.md — GPLv3, коммерческие условия и атрибуция Telegram Web K
Локально всё запускается двумя командами и не требует внешней инфраструктуры: у каждой зависимости (хранилище, брокер, шина, rate limit, пуши, почта) есть in-memory реализация, и она выбрана по умолчанию.
make setup # проверит инструменты, найдёт свободные порты, сгенерит VAPID-ключи make dev # соберёт и поднимет всё, стримит логи
Дальше http://localhost:5173.
Мобильные приложения собираются из mobile/ обычным flutter build — но им нужен Rust-тулчейн, потому что сборка приложения собирает и крипто-крейт:
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \ aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin cargo install cargo-ndk # Android; Gradle-таска зовёт именно его cd mobile && flutter build apk
Сгенерированные Dart и Rust для FFI закоммичены, так что шаг кодогенерации при обычной сборке не нужен.
Пет-проект «отправлять пуши с сайта» отлично живёт на трёхстах строках. Но если довести мысль до конца — «а почему, собственно, мои сообщения должны проходить через чей-то чужой сервер, и почему этот сервер должен уметь их читать» — то дорога упирается в MLS, в федерацию и в нодлист, подписанный Ed25519, который по сути является нодлистом FidoNet тридцатилетней давности с другой криптографией.
Иногда старые идеи возвращаются не потому, что мы ностальгируем, а потому, что они были правильные.