Блокировки это одно. Их обходят: VLESS, Reality, прокси, про это уже написано много, в том числе у нас. Но есть сценарий жёстче. Интернета нет вообще. Не «YouTube не открывается», а мобильную сеть увели в ноль, Wi-Fi бесполезен, потому что аплинк перекрыт. Такое включают точечно: митинг, площадь, район, иногда целая страна на несколько часов.
Вопрос простой: могут ли два телефона в этой ситуации всё равно обменяться сообщением. Без вышек, без интернета, без сервера. Ответ: да, но честный ответ длиннее, и в нём много «но». Мы (команда из трёх человек, делаем мессенджер RCQ) собрали для этого режим, который называется Radio. Ниже что он реально умеет, чего не умеет, и какие грабли мы собрали по дороге.
Сначала о честности: «городского mesh» почти не существует
В жанре «мессенджер без интернета» принято обещать mesh: телефоны образуют сеть, сообщение прыгает с устройства на устройство и доходит до адресата на другом конце города, даже если вас разделяют сотни метров. Звучит красиво. На стоковых телефонах это в основном не работает.
Причины приземлённые. У смартфона два коротких радио: Bluetooth и Wi-Fi. Дальность Bluetooth это метры, в лучшем случае десятки метров в прямой видимости. Wi-Fi Direct добивает дальше, но это всё равно прямая связь точка-точка, а не магия. Чтобы сделать настоящий многохоповый mesh, нужно, чтобы на каждом промежуточном телефоне крутился сервис, который принимает чужой трафик, ретранслирует его дальше, делает это в фоне, не сажая батарею в ноль, и при этом не ломается, когда вокруг сотни таких же устройств. Операционные системы фоновую работу с радио душат намеренно, ради той же батареи. История Bridgefy, который продавали протестующим как mesh, а потом исследователи показали, что там и с шифрованием, и с самой mesh-моделью большие проблемы, хороший памятник этим обещаниям.
Поэтому мы сразу решили: не врать. RCQ Radio это не «mesh на весь город». Это связь с теми, кто физически рядом. Один хоп, прямая радиосвязь, небольшой радиус. Внутри этих рамок оно работает честно. За их пределами мы ничего не обещаем.
Два слоя: маячок и труба
Наивная реализация делает всё на одном радио и упирается либо в дальность, либо в полосу, либо в батарею. Мы разнесли задачу на два слоя.
Первый слой это присутствие, «кто рядом». Здесь работает Bluetooth Low Energy. Телефон постоянно шлёт крошечный маячок и слушает чужие. BLE для этого идеален: дёшево по энергии, не требует соединения. На Android мы не берём Google Nearby, а работаем с радио напрямую, через BluetoothLeAdvertiser и BluetoothLeScanner. Плата за дешевизну это размер: в legacy-рекламу BLE влезает около 24 байт полезных данных. В них надо упаковать магический префикс, версию, флаги (комната или личный чат, есть ли пароль), короткий идентификатор сессии и обрезанное имя. Полное имя долетает уже после соединения, отдельным кадром.
Второй слой это сами данные. Здесь нужна полоса, а не экономия, поэтому включается Wi-Fi Direct. На Android это WifiP2pManager: одно устройство становится владельцем группы, поднимает у себя TCP-сокет (у нас порт 8989, кадры с префиксом длины, потолок 512 КБ на кадр), остальные подключаются к нему. Обнаружение внутри группы идёт через DNS-SD с TXT-записью, в которой лежит идентификатор сессии. На iOS мы не дёргаем эти примитивы вручную, а берём Apple MultipeerConnectivity (MCSession, тип сервиса rcq-radio), который сам жонглирует комбинацией Wi-Fi и Bluetooth под капотом.
Разделение простое: BLE отвечает на вопрос «кто вокруг», Wi-Fi Direct и MultipeerConnectivity тащат полезную нагрузку.
Топология: звезда, а не паутина
Это самый важный технический факт, и мы проговариваем его прямо. Топология у Radio это звезда с одним центром, а не многохоповая паутина.
Когда вы создаёте комнату или зовёте человека в личный чат, ваш телефон становится центром: владельцем Wi-Fi Direct группы на Android или хостом сессии на iOS. Все остальные подключаются напрямую к нему. Центр получает кадр от одного участника и рассылает его остальным, кто подключён к нему же. Всё. Никакого TTL, никакого флуда, никакой пересылки «дальше по цепочке». Устройство, которое не подключено к центру напрямую, в разговоре не участвует. Комнаты маленькие (на iOS потолок 8 участников).
Радиус это радиус прямой связи: метры для Bluetooth, до сотни метров в прямой видимости для Wi-Fi Direct. Этого хватает на этаж, на двор, на вагон, на небольшую площадь. Этого не хватает, чтобы дотянуться через район. И мы не делаем вид, что хватает.
Можно было бы попробовать собрать многохоп самим, поверх этих же радио. Мы сознательно не стали, и не только из-за батареи и фоновых ограничений ОС. У многохопа есть цена, о которой в рекламе mesh молчат: промежуточный узел это телефон постороннего человека, через который идёт ваш трафик. Он его пересылает, видит факт пересылки, может его придержать или подменить, становится точкой для спама и для деанона по тому, кто рядом с кем оказался. Чтобы сделать это безопасно, нужна целая модель доверия к незнакомым ретрансляторам, и на стоковых телефонах она быстро превращается в фикцию. Честный один хоп, где вы видите, к кому именно подключились, нам кажется более правдивым, чем красивая паутина, которой нельзя доверять.
Шифрование без сервера
В обычном режиме RCQ шифрует переписку через libsignal с forward secrecy. Но libsignal по дизайну опирается на сервер: там лежат prekey-бандлы, через них устройства делают первичный обмен. В офлайне сервера нет. Значит, для Radio нужен отдельный путь, и он есть.
Личный чат: оба устройства генерируют эфемерную пару ключей Curve25519, обмениваются публичными ключами в открытую (на iOS это едет в invitation-контексте MultipeerConnectivity), считают общий секрет через ECDH, прогоняют его через HKDF-SHA256 с info-строкой rcq-radio-1to1 для доменного разделения. На выходе 32-байтный ключ AES.
Комнаты: открытая комната выводит ключ как SHA256 от идентификатора комнаты, кто увидел строку, тот и зашёл. Комната с паролем выводит ключ через PBKDF2-SHA256, 100 тысяч итераций, солью служит идентификатор комнаты (около 200 мс на iPhone 12). Проверка пароля честная и неявная: пока первый кадр от хоста не расшифровался, вы «зашли, но в муте». Расшифровался, значит ключ совпал, вы в комнате. Не расшифровался, значит пароль не тот, вас выкидывает.
Само сообщение запечатывается в AES-GCM: 12-байтный одноразовый nonce на каждое сообщение, 128-битный тег аутентификации. На обеих платформах одна и та же схема (rcq-radio-1to1, те же параметры комнат), хотя транспорты разные.
Два важных следствия. Первое: UIN, ваша личность в сети RCQ, в эфир не уходит вообще. В радиоэфире вас видно как четырёхбайтный эфемерный идентификатор сессии и анонимное имя-метку. Второе: ничего не оседает на диск. Сообщения Radio живут только в оперативной памяти сессии, в основную базу не пишутся, разорвалась связь и переписки нет нигде. Это сознательно: офлайн-разговор не оставляет следа.
Грабли по дороге
24 байта это очень мало. Первая версия упаковки маячка не влезала в бюджет legacy-рекламы BLE, и часть устройств её просто не показывала. Пришлось резать всё до минимума и принять, что в маячке едет огрызок имени, а полное имя досылается после соединения.
Wi-Fi Direct это диалог с пользователем. Формирование группы на Android требует согласия через системный диалог, и переговоры о том, кто станет владельцем группы, идут не мгновенно. На iOS отдельная боль была с приглашениями MultipeerConnectivity. Если приглашение «повисало» (пользователь смахнул шит, не нажав ни «принять», ни «отклонить»), сторона-инициатор сидела в состоянии .connecting до 30-секундного таймаута, а потом схлопывалась в «УШЁЛ». Именно этот симптом нам и приносили тестеры. Лечится тем, что любой не-обработанный явно invite мы теперь авто-отклоняем, чтобы транспорт не висел.
Эмулятор бесполезен. У эмулятора нет ни BLE-радио, ни Wi-Fi Direct, ни MultipeerConnectivity. Крипто-слой мы покрыли юнит-тестами (симметрия ECDH, round-trip seal/open, детерминизм ключей комнаты, отсев по паролю проходят на устройстве). Но весь транспорт проверяется только так: берёшь два настоящих телефона и идёшь с ними по коридору, проверяя, на каком расстоянии связь рвётся.
Платформы не дружат между собой. Android-Radio построен на сыром BLE плюс WifiP2pManager, iOS-Radio на MultipeerConnectivity. Это разные стеки, которые между собой не стыкуются на уровне транспорта. Поэтому пока Radio работает в пределах одной платформы: Android с Android, iOS с iOS. Кросс-платформенный мост это отдельная большая задача, и обещать её сейчас мы не будем.
Бонусом по той же Wi-Fi Direct трубе работает push-to-talk голос (сырой PCM, 16 кГц, кадрами по 40 мс), рация в чистом виде. Полоса локальной сети это позволяет.
Что это НЕ решает
Раздел, без которого статья была бы рекламой.
Это не обход блокировок на расстоянии. Если вы и собеседник в разных частях города, Radio не поможет, ему нужна прямая радиосвязь. Для «интернет есть, но всё заблокировано» у нас другой механизм (VLESS и Reality внутри приложения), это про другое.
Это не многохоповый mesh. Сообщение не путешествует через чужие телефоны. Один хоп, и точка.
Метаданные присутствия видны. Любой с BLE-сканером рядом увидит, что какое-то устройство RCQ светит маячок, и анонимную метку-имя. Содержимое зашифровано AES-GCM, но сам факт «тут кто-то есть в эфире» по радио не спрячешь.
Батарея. Постоянное BLE-сканирование плюс активная Wi-Fi Direct группа греют телефон и сажают заряд. Это режим «когда нужно», а не «всегда включено».
Аудита у нас не было. Схему мы описываем честно, но независимой проверки третьей стороной не проходили. iOS-клиент открыт под AGPL-3.0, код можно читать.
Когда оно реально полезно
Не «вместо интернета всегда», а в конкретных дырах. Площадь или район, где сеть увели в ноль, а люди стоят рядом. Этаж здания, подвал, бункер, куда не достаёт ни одна вышка. Самолёт, поезд, глухой поход. Фестиваль, где сота перегружена и не отвечает. Везде, где люди физически близко, а инфраструктуры нет.
Внутри этих рамок два телефона RCQ всё равно поговорят: личным чатом или комнатой, с шифрованием, без сервера и без следа на диске. За этими рамками мы вам ничего не обещаем, и считаем, что честно сказать об этом важнее, чем нарисовать на коробке слово «mesh».
Android-клиент уже в проде, режим Radio в нём есть. iOS идёт через TestFlight. Ссылки и исходники iOS (AGPL-3.0) на rcq.app.
