
Введение
В предыдущих своих работах я писал, что анонимную сеть Hidden Lake можно внедрить в любую систему, где существует возможность отправления и получения сообщений. За счёт такой имплементации появляется возможность использовать выбранную систему для генерации анонимного трафика и скрывать свои действия в ней же. Именно таким образом я поступил, когда реализовывал адаптер HL под централизованный сервис chatingar (статья на хабре), где в комментариях под постами сетью автоматически генерировались шифртексты.
И то, что анонимную сеть можно внедрить в любую систему - я не соврал, а вот сколько это будет требовать умственных и временных ресурсов - я не уточнил. Если система крайне требовательна к поведению клиентов, способна банить, выдавать каптчи, следить за аномальным трафиком и прочее - нельзя просто так вставить работу HL и навесить обычные HTTP-адаптеры на загрузку / выгрузку сообщений (как я собственно сделал в chatingar). Требуется уметь обходить защиты, быть ниже травы, тише воды. Всё это безусловно будет приводить к дополнительным задержкам в передаче данных, а также к необходимости уменьшения + рандомизации размера шифртекстов.
И к чему весь это монолог я здесь привожу. Суть в том, что текущая реализация анонимной сети Hidden Lake придерживается гибридной схемы шифрования, где в качестве асимметричных алгоритмов были выбраны ML-KEM-768 и ML-DSA-65, которые не позволяют шифртексту быть меньше ~4KiB (из-за инкапсуляции ключа и подписи сообщений). Кажется что проблемы здесь не сильно большие - просто дели эти 4KiB+Payload на части по N байт и всё будет работать.
Теоретически - это так, на практике же ... (╯ ° □ °) ╯ (┻━┻)
Наиболее детально вся проблема, как ни странно, ощущается даже не в централизованных системах, которые очень любят банить своих пользователей за чрезмерно автоматическую активность, а в децентрализованных mesh-сетях по типу Meshtastic/LoRa, где размер сообщений и частота их отправления жёстко привязаны как к физическим лимитам, так и к логическим. Чтобы описать более подробно картину происходящего, давайте попробуем рассчитать то с какой эффективностью я смогу отправлять шифртексты в эту Mesh-систему при имеющейся реализации Hidden Lake.
Вкратце об анонимной сети Hidden Lake
Анонимная сеть Hidden Lake (HL) - это децентрализованная F2F (friend-to-friend) анонимная сеть с теоретической доказуемостью на базе очередей (QB-задача). В отличие от известных анонимных сетей, подобия Tor, I2P, Mixminion, Crowds и т.п., сеть HL способна противостоять атакам глобального наблюдателя. Сети Hidden Lake для анонимизации своего трафика не важны такие критерии как: 1) уровень сетевой централизации, 2) количество узлов, 3) расположение узлов и 4) связь между узлами в сети.
Более подробный анализ сети Hidden Lake можно найти в работе:
-> Анонимная сеть «Hidden Lake».Репозиторий Hidden Lake: https://github.com/number571/hidden-lake
Репозиторий проекта go-peer: https://github.com/number571/go-peer
QB-задача
Задача на базе очередей может быть описана следующим списком действий:
Каждое сообщение m шифруется ключом получателя k: c = Ek(m),
Сообщение c отправляется в период = T всем участникам сети,
Период T одного участника независим от периодов T1, T2, ..., Tn других участников,
Если на период T сообщения не существует, то в сеть отправляется ложное сообщение v без получателя (со случайным ключом r): c = Er(v),
Каждый участник пытается расшифровать принятое им сообщение из сети: m = Dk(c).

Для любых пассивных наблюдателей, включая и глобального наблюдателя, QB-задача считается трудновыполнимой, потому как она скрывает не только связь между узлами, но и состояние таковых узлов: 1) отправляет ли анализируемый узел какие-нибудь сообщения?; 2) принимает их?; 3) или вовсе бездействует? - таковые вопросы порождает задача.
Расчёты при текущей реализации HL
Итак, стандартная конфигурация сети Hidden Lake - это сообщение размером в 8KiB (где ~4KiB - это заголовочные данные, ~4KiB - это полезная нагрузка) и период генерации в 5 секунд. Итого получаем, что за одну секунду одним узлом генерируется 1.6KiB информации. Если мы хотим отправить сообщение размером в 8KiB в Meshtastic/LoRa сеть, то потребуется его раздробить на куски по 200 байт. Округлим 8KiB до 8KB и получим 40 кусков. Предположим далее, что каждый кусок будет отправляться в сеть раз в секунду. В таком случае одно сообщение будет генерироваться раз в 40 секунд (вместо 5 секунд первоначальных) - печально, но невелика беда.
Беда велика ... ┬─┬ノ( º _ ºノ)
При отправлении сообщений с такой частотой (будь то на LongFast или на MediumFast) - радиоэфир Mesh-сети просто заглохнет в потоке спама и коллизий. В радиопередаче существует правило 1% времени на запись, всё остальное - 99% времени на чтение, чтобы не перенагружать канал. В Meshtastic существует встроенная логика (chUtil - channel utilization), которая будет также самолично следить за количеством отправляемых пакетов. И если эта шкала будет превышать 50%, то отправляемые сообщения уже не будут нормально доходить до нужных узлов. Meshtastic на этот случай ещё подготовил механизм собственной блокировки (Duty Cycle) при которой вы не сможете какое-то время отправлять пакеты, пока внутренний таймер 1w/99r не сбросится. Плюс к этому и законодательно (в России и в Европе) нельзя перенагружать эфир, в противном случае к вам может заглянуть дядюшка майор ГКРЧ (государственная комиссия по радиочастотам) с подарком в виде штрафа.
В результате всего вышесказанного, нам необходимо посчитать с каким периодом мы можем корректно отправлять один пакет в 200 байт. Хоть в зависимости от различных частотных диапазонов процентаж записи может быть как ниже, так и выше 1% - лучше всё же ориентироваться на этот один процент. Если мы находимся в городской местности, то выбираем в качестве основы MediumFast пресет - чистая дальность передачи уменьшается (по сравнению с LongFast), но и также уменьшается общее время нахождения в эфире.
При отправлении пакета размером в 200 байт и пресете MediumFast, скорость записи будет составлять ~0.5 секунд. Чтобы придерживаться правила 1% мы должны умножить 0.5 на 100 и тем самым получим 50 секунд - период генерации каждого пакета. Но есть ещё одно НО. Под отправлением мы понимаем самоличную отправку сообщения, а Meshtastic под этим понимает также и ретрансляцию пакетов от других узлов - т.е. любое отправление де-факто. Предположим, что рядом с каждым узлов (включая и нас) находится всегда по 3 узла, итого суммарно наш пакет уже занимает 2 секунды эфирного времени для всей сети => 200 секунд (> 3 минут) период генерации.
Теперь же возвращаемся к нашим 8KB - надеюсь вы ещё о них не забыли. 8KB - это 40 пакетов по 200 байт, каждый такой пакет должен отправляться в сеть раз в ~200 секунд. Итого, 8000 секунд или ~133.3 минуты, или ~2.2 часа. Если реализуется связь "запрос-ответ", то получим уже ~4.4 часа. Это ещё мы не берём в расчёт возможности потери / ретраев пакетов по мере передачи, а также необходимость правильной разбивки и мёржа всех переданных 8KB. Таким образом, можно смело и это время умножать вдвое: ~8.8 часа на запрос к ресурсу HL поверх Meshtastic/LoRa, чтобы получить ответ. За это время половина аккумулятора израсходуется, не говоря уже о сомнительности самого прикладного использования.
Поэтому пришлось что-то менять в самой архитектуре Hidden Lake. Но для начала ...
Зачем вообще нужна анонимность в Mesh-сетях?
Если мы берём за основу Meshtastic/LoRa, то таковая сеть действительно является безопасной за счёт возможности создания приватных чатов - каналов с указанным ключом шифрования. Никто сторонний кроме вас и собеседников (конечно же при отсутствии компрометации ключа) не сможет просматривать сообщения. Вы также можете выставить роль клиента на CLIENT_HIDDEN, чтобы не отображаться в списке других узлов, отключить GPS, телеметрию и т.д. Эти меры, так или иначе, положительно сказываются на конфиденциальности и анонимности, но при этом полноценно не решают проблему сокрытия связи между несколькими абонентов.
Представим такой сценарий, что существует злоумышленник очень интересующийся вашей личной жизнью. Он знает, что вы увлекаетесь Meshtastic'ом и уже купил себе такое же устройство в большом количестве экземпляров. Вокруг вашего здания он расставил четыре схемы: на юге, севере, западе и востоке, а также объединил их в одну считывающую систему, которая определяет, что отправленный сигнал был изнутри окружённого периода. Таким образом, он смог получить +- точную информацию о том, когда появляется новый пакет в сети. И велика вероятность, что таковой пакет создавали именно вы. Meshtastic не шифрует заголовки, а потому злоумышленник с лёгкостью определяет ID вашего узла. Теперь из любой точки города он может узнать по полученному пакету - принадлежит ли он вам.
Далее, злоумышленник предполагает, что основное пользование Meshtastic'ом с вашей стороны - это чатинг с другими абонентами. Таким образом, он пытается выяснить: не отправляет ли вам кто-либо сообщения, сверяя заголовки From=<кто-то> и To=<вы>. Если злоумышленник успешно перехватил пакет, в котором кто-то отправляет вам сообщения, злодей записывает его ID в свою персональную тетрадочку. Теперь его задача меняется - он устанавливает Meshtastic/LoRa устройства по различным уголкам города относительно той стороны света с устройства которого был получен данный пакет, и конечно же объединяет их в одну систему. Эта система должна будет сверять где чаще всего появляется впервые пакет от ID неизвестного отправителя. В результате, по мере продвижения / "миграции" устройств злоумышленника диапазон отправления с каждым разом будет становиться всё меньше пока он не дойдёт до конкретного района, улицы и наконец здания. Уже далее по априорным / косвенным данным, будь то ваша работа, школа в которой вы учились, список друзей в социальных сетях и прочее, злоумышленник будет иметь список лиц, с которыми вы потенциально можете общаться и просто будет следить за людьми возле этого здания. Если будет найдено совпадение, то очень велика вероятность, что вы общались именно с данным человеком.
Связь деанонимизирована ... ༼ つ ◕_◕ ༽つ
Окей, а что если абонентами используется Broadcast связь? В таком случае поле To всегда будет равно Broadcast (0xFFFFFFFF). При подобном сценарии у злоумышленника остаётся только ваш ID, а отправляемые пакеты, поступающие извне, - неопределены. На этот случай наш злодей может сверять паттерн связи: если вы отправляете сообщение, то есть шанс, что вы получите также ответ. Например, сообщения "доброе утро", "привет", "как дела?" и т.д. часто подразумевают ответ от другого абонента. Таким образом, злоумышленник несколько дней возле вас накапливает все пакеты отправленные от других абонентов, а потом начинает сверять возможность связи по дельте в 5, 10 минут, в 1, 2 часа от момента отправки вашего сообщения, отбрасывая тех кто не уложился в интервал, и накапливая список тех кто уложился. Далее, наблюдатель использует пересечение наиболее частых совпадений и находит ограниченное количество узлов (их ID). А далее, весь алгоритм поиска этих узлов уже никак не отличается от ранее мной написанного.
И тут связь деанонимизирована ... ‿( ́ ̵ _- ` )‿
Пугает в этой истории то, что не надо быть глобальным наблюдателем (как в сети Интернет), чтобы успешно деанонимизировать связи людей. Вполне достаточно иметь N-ое количество устройств, время, терпение и цель. В результате чего, любой локальный наблюдатель в децентрализованных Mesh-сетях вполне корректно может начать переходить в некое подобие глобального наблюдателя.
Почему именно Hidden Lake?
В настоящее время существует огромное количество различного вида анонимных сетей, начиная со знаменитых Tor / I2P и заканчивая менее известными по типу Crowds, Mixminion, Perfect Dark, GNUnet и т.д. В чём сложность условной имплементации Tor или I2P на рельсы Mesh-сетей по типу Meshtastic/LoRa? Ответ: их инфраструктура и ... эффективность. Большинство анонимных сетей ориентированы на TCP/IP (или вернее OSI) стек, где существует IP-адресация, транспортная логика передачи UDP/TCP, криптографический TLS протокол. Всё это, помимо накладных расходов на размер сообщений, будет также влиять и на нагруженность Mesh-сети. Ожидание в таковых анонимных сетях составляет миллисекунды и максимум секунды, но никак не минуты или даже часы. Подобные сети всегда будут пытаться находить наиболее оптимальные маршруты передачи, которых в привычном плане (из сети Интернет) в Mesh-сетях и нет. Сообщения отправляются не по классической цепочке маршрутизирующих узлов, а через радиовещание всем близлежащим узлам. Таким образом, если и имплементировать Tor / I2P под Mesh-сети (по типу Meshtastic/LoRa, здесь я не говорю о сетях по типу Yggdrasil), то их придётся кардинально изменить. Получится в итоге корабль Тесея из мира анонимных сетей.
В результате, для успешной имплементации анонимной сети поверх Meshtastic/LoRa, таковая сеть должна быть минималистичной в инфраструктурном плане - не требовать дополнительные сервера, не требовать кооперации узлов с целью дальнейшей маршрутизации, желательно иметь теоретически доказуемую анонимность от глобального наблюдателя, иметь возможность задерживать / аккумулировать сообщения и передавать малые пакеты. Сеть Hidden Lake почти идеально подходит под данные условия. Исключение лишь в малом размере сообщений, который должен быть <= 200 байт. По этой причине мне пришлось видоизменять криптографический протокол, делая его более легковесным.
Также, как ни странно, но и сам Meshtastic/LoRa идеально подходит для сети Hidden Lake в том плане, что последней для полноценной и корректной своей работы требуется, чтобы узлы находились в онлайн статусе постоянно. В противном случае, если узел выходит в онлайн только для того, чтобы отправить сообщение и потом переходит сразу в оффлайн статус - это приводит к закономерностям и соответствующей деанонимизации. "Философия" же Meshtastic/LoRa спроектирована как раз таким образом, чтобы оставаться в сети даже тогда, когда не требуется что-либо отправлять - при таком сценарии радиомодуль просто исполняет роль поддержания сети и ретрансляции сообщений от других узлов.
Замена гибридной схемы на симметричную
Криптографический протокол сети Hidden Lake состоит из двух этапов шифрования, где первый выполняет роль непосредственной защиты полезной нагрузки, а второй выполняет роль разграничения подсетей по ключу, чтобы их нельзя было легко мёржить. При имплементации сети в среду Meshtastic/LoRa второй этап шифрования становится избыточным, т.к. он необходим в глобальных сетях по типу Интернет. Таким образом, 78 байт заголовков не будут участвовать в передаче. Теперь же, что касается первого этапа шифрования - он выглядит следующим образом:
где k' – сеансовый ключ шифрования, рассчитанный на одно сообщение, s – криптографическая соль, рассчитанная на одно сообщение, m – открытое сообщение, pubA, pubB – публичные ключи участников A, B соответственно, privA – приватный ключ участника A, h – результат хеширования, S – функция подписания, f – функция дополнения сообщения до константной величины, H – функция хеширования, HMAC – функция вычисления имитовставки на базе хеш-функции H. В этой схеме предполагается, что A – есть отправитель информации m, B – есть получатель данной информации.
В общей сумме, самыми ресурсными элементами здесь являются KEM: и DSA:
, которые и занимают ~4KiB (1088 KEM + 3309 DSA = 4397). Всё остальное занимает 172 байт, что тоже не очень хорошо, когда у нас лимит всего в 200 байт. И это ещё без учёта накладных расходов на работу прикладных сервисов самой Hidden Lake. Таким образом не получится просто заменить KEM на X25519 или PSK (Pre-Shared key), а подпись на ED25519 или HMAC от ключа абонента - размер уже будет стремиться или превышать 200 байт. Таким образом, необходимо заменить данную схему полностью на более минималистичную. Также, не менее важным условием является сохранение устойчивости сети к постквантовой криптографии, из-за чего нельзя применять асимметричные алгоритмы на эллиптических кривых (которые так и манят малым размером ключей).
В результате, была создана следующая схема:
где k – общий секрет абонентов, k' – сеансовый ключ шифрования, s – криптографическая соль, рассчитанная на одно сообщение, m – открытое сообщение, f – функция дополнения сообщения до константной величины, KDF – функция формирования ключа, EAE – функция аутентифицированного шифрования.
Безопасность данной функции зависит непосредственно от качества энтропии общего секрета k, который участвует далее в выработке сеансовых ключей k', от качества ГСЧ / КСГПСЧ, которым генерируется криптографическая соль s, и также от безопасности самих функций аутентифицированного шифрования EAE и формирования ключей KDF.
Функция KDF в совокупности с криптографической солью s необходимы в первую очередь для защиты от возможных атак на основе парадокса дней рождения, рассчитанных на вектор инициализации (IV / nonce) функции аутентифицированного шифрования (преимущественно при режиме шифрования GCM, где значение nonce равняется 96-бит). При использовании одинакового ключа шифрования и при рандомизации nonce, количество генерируемых сообщений не должно превышать 2nonce-bit/2 (для GCM – 232 по рекомендации NIST [13]). При использовании KDF от случайной величины s данное ограничение снимается. Итого, схема занимает 48 байт (salt - 16, nonce - 12, authTag - 16, msgLen - 4).
Может также возникнуть вполне логичный вопрос: а зачем вообще нужна данная функция шифрования, если Meshtastic и так умеет шифровать сообщения? У меня есть несколько аргументов за дополнительное шифрование:
Не Meshtastic'ом едины. Данную функцию шифрования можно применять не только в среде Meshtastic/LoRa, но и в любой другой среде, в том числе на каких-нибудь публичных централизованных сервисах, где шифрования по умолчанию не существует,
Для личных сообщений Meshtastic использует асимметричный алгоритм X25519, который будет уязвим при постквантовом периоде. Его взлом будет гарантировать прошлую и дальнейшую дешифровку сообщений,
Не были ли внедрены в Meshtastic бэкдоры на уровне аппаратной части по мере транспортировки платы к вам? Или при прошивке устройства через flasher? Мысль параноидальная, но если это так - дополнительное шифрование на моменте формирования полезной нагрузки позволит обезопасить связь.
Замена схемы в коде

Когда планировал заменять гибридную схему на симметричную, я думал, что мне придётся переписать пол проекта, где была завязана логика работы с публичными ключами. При симметричной схеме больше не существует чёткой идентификации узлов, а существуют лишь связи между данными узлами. Но интерфейсы творят чудеса, основную часть которую надо было мне заменить - это интерфейс схемы, всё остальное же работало в привычном темпе, потому как QB-задача не зависит от схемы шифрования и может работать также эффективно на симметричных алгоритмах, как и на асимметричных.
Так например, раньше интерфейсы шифратора и дешифратора выглядили следующим образом, с конкретной привязкой к публичным ключам:
type IDecryptor interface { DecryptMessage(asymmetric.IMapPubKeys, []byte) (asymmetric.IPubKey, []byte, error) } type IEncryptor interface { EncryptMessage(asymmetric.IPubKey, []byte) ([]byte, error) }
Сейчас же это выглядит так:
type IDecryptor interface { DecryptMessage(IKeysContainer, []byte) (IParticipantKey, []byte, error) } type IEncryptor interface { EncryptMessage(IParticipantKey, []byte) ([]byte, error) }
Мало что изменилось, лишь понятия ключей и контейнеров стали более абстрактными. В результате, я понял, что поддерживать сразу две схемы шифрования будет не так уж и сложно, как считал раньше и вовсе не нужно из проекта удалять всю асимметрию. К тому же, сама эта разница между гибридной схемой и симметричной существует лишь в одном компоненте сети Hidden Lake - в ядре HLK. Адаптеры HLA и прикладные сервисы HLS никак не задействуют логику ключей, а потому даже не потребуется создавать теги компиляции. Всё можно сделать на уровне конфигурации ядра HLK, что я и сделал:
settings: crypto_scheme_type: "hybrid" # OR "symmetric"
Создание адаптера под Meshtastic
Всё что теперь осталось - это лишь адаптировать трафик Hidden Lake, который наконец-то смог вполне корректно работать с пакетами малого размера, на Meshtastic/LoRa радиомодуль. Первоначально я думал использовать Go пакет для работы с Meshtastic, но оказалось, что официальной библиотеки под Go просто не существует. Сторонних и массово используемых решений также не существует. В результате, пришлось писать основную часть принятия / отправления пакетов при помощи поддерживаемой Python библиотеки.
Сервис на Python был написан специально таким, чтобы он не знал о какой бы то ни было логике шифрования / анонимизации трафика. Вся его задача сводилась бы только к отправлению и принятию пакетов из Mesh-сети. Вся другая же логика валидации и дешифрования будет возложена на адаптер HLA=meshtastic и ядро HLK.
Получение пакетов из сети было организовано следующим образом. Как только я получаю новое сообщение из сети и успешно его декодирую (PRIVATE_APP, base64 decode), я его помещаю в список полученных сообщений с указанием из какого канала оно было получено. Когда HLA=meshtastic решает получить список этих сообщений - он обращается к ручке по методу GET, после чего список сообщений обнуляется.
mutex = threading.Lock() received_binary_messages = [] def on_receive_packet(packet, interface): global received_binary_messages try: if 'decoded' in packet: decoded = packet['decoded'] portnum = decoded.get('portnum') channel = packet.get('channel', 0) if portnum == 'PRIVATE_APP' or 'payload' in decoded: payload_bytes = decoded.get('payload', b'') if payload_bytes: base64_str = base64.b64encode(payload_bytes).decode('utf-8') message_data = {"channel": channel, "message": base64_str} with mutex: received_binary_messages.append(message_data) except Exception: pass @app.get("/", summary="Get a list of incoming binary messages") async def recv_binary_messages(): global received_binary_messages with mutex: return_messages = received_binary_messages.copy() received_binary_messages = [] return return_messages
Для отправления я использую порт PRIVATE_APP, чтобы другие пользователи (при использовании основного канала) не видели шифрованного спама в сообщениях. Также я дополнительно проверяю, смог ли я подключиться по USB порту к Meshtastic. Если нет, то пытаюсь подключиться вновь. Если же подключение уже существует, то необходимо просто проверить размер сообщения (не больше 200 байт) и отправить его в сеть с указанным каналом.
@app.post("/", summary="Send binary data to the mesh network") async def send_binary_message(payload: dict = Body(...)): """Takes Base64, decodes it into bytes and sends it to Mesh.""" global interface global is_connected if not is_connected: if interface: try: interface.close() except Exception: pass # Ignore errors during forced cleanup interface = None try: interface = meshtastic.serial_interface.SerialInterface(devPath=DEV_PATH) is_connected = True except Exception as e: raise HTTPException(status_code=503, detail="Serial interface is unavailable") try: raw_bytes = base64.b64decode(payload["message"]) except Exception: raise HTTPException(status_code=400, detail="Invalid Base64 format in the message field") if len(raw_bytes) > 200: raise HTTPException(status_code=400, detail="The data size exceeds the Mesh packet limit (~200 bytes)") try: await asyncio.to_thread( interface.sendData, data=raw_bytes, channelIndex=payload["channel"], portNum=portnums_pb2.PortNum.PRIVATE_APP ) return {"status": "success", "bytes_sent": len(raw_bytes)} except Exception as e: is_connected = False raise HTTPException(status_code=500, detail=f"Error sending: {str(e)}")
На этом в принципе вся основная логика Python скрипта и заканчивается.
Полный код скрипта
import asyncio import base64 import threading import os import signal import warnings import meshtastic.serial_interface from fastapi import FastAPI, HTTPException, BackgroundTasks, Body from meshtastic import portnums_pb2 from pubsub import pub try: DEV_PATH = {{devPath}} # example: "/dev/ttyUSB1" if DEV_PATH == "": raise except Exception: DEV_PATH = None # default try: SRV_ADDR = {{srvAddr}} # example: "127.0.0.1:8080" if SRV_ADDR == "": raise except Exception: SRV_ADDR = "127.0.0.1:0" # default # Suppress all deprecation warnings globally warnings.filterwarnings("ignore", category=DeprecationWarning) app = FastAPI(title="Meshtastic Serial Binary HTTP Gateway") mutex = threading.Lock() received_binary_messages = [] interface = None is_connected = False class MeshtasticConnectionError(Exception): """Exception raised when a Meshtastic hardware device fails to respond.""" pass def on_receive_packet(packet, interface): """Callback for processing all incoming packets from the Mesh network.""" global received_binary_messages try: if 'decoded' in packet: decoded = packet['decoded'] portnum = decoded.get('portnum') channel = packet.get('channel', 0) if portnum == 'PRIVATE_APP' or 'payload' in decoded: payload_bytes = decoded.get('payload', b'') if payload_bytes: base64_str = base64.b64encode(payload_bytes).decode('utf-8') message_data = {"channel": channel, "message": base64_str} with mutex: received_binary_messages.append(message_data) except Exception: pass @app.on_event("startup") async def startup_event(): global interface global is_connected pub.subscribe(on_receive_packet, "meshtastic.receive") try: interface = meshtastic.serial_interface.SerialInterface(DEV_PATH) if interface.myInfo: is_connected = True else: raise MeshtasticConnectionError("Failed connect to node via Serial") except Exception: pass @app.on_event("shutdown") async def shutdown_event(): global interface global is_connected if interface: interface.close() is_connected = False os.kill(os.getpid(), signal.SIGTERM) @app.get("/", summary="Get a list of incoming binary messages") async def recv_binary_messages(): """Returns the history of received packets with Base64 strings.""" global received_binary_messages with mutex: return_messages = received_binary_messages.copy() received_binary_messages = [] return return_messages @app.post("/", summary="Send binary data to the mesh network") async def send_binary_message(payload: dict = Body(...)): """Takes Base64, decodes it into bytes and sends it to Mesh.""" global interface global is_connected if not is_connected: if interface: try: interface.close() except Exception: pass # Ignore errors during forced cleanup interface = None try: interface = meshtastic.serial_interface.SerialInterface(devPath=DEV_PATH) is_connected = True except Exception as e: raise HTTPException(status_code=503, detail="Serial interface is unavailable") try: raw_bytes = base64.b64decode(payload["message"]) except Exception: raise HTTPException(status_code=400, detail="Invalid Base64 format in the message field") if len(raw_bytes) > 200: raise HTTPException(status_code=400, detail="The data size exceeds the Mesh packet limit (~200 bytes)") try: await asyncio.to_thread( interface.sendData, data=raw_bytes, channelIndex=payload["channel"], portNum=portnums_pb2.PortNum.PRIVATE_APP ) return {"status": "success", "bytes_sent": len(raw_bytes)} except Exception as e: is_connected = False raise HTTPException(status_code=500, detail=f"Error sending: {str(e)}") @app.delete("/", summary="Shutdown HTTP listening service") async def shutdown(background_tasks: BackgroundTasks): """Sends a termination signal to the current process.""" background_tasks.add_task(shutdown_event) return {"status": "success", "message": "Server shutting down..."} if __name__ == "__main__": import uvicorn host, _, port = SRV_ADDR.rpartition(":") uvicorn.run(app, host=host.strip("[]"), port=int(port))
Теперь же надо рассмотреть логику самого адаптера HLA=meshtastic. Все адаптеры в сети Hidden Lake имеют две интерфейсные функции: Produce и Consume.
type IAdapter interface { IProducer IConsumer } type IProducer interface { Produce(context.Context, layer1.IMessage) error } type IConsumer interface { Consume(context.Context) (layer1.IMessage, error) }
Функция Produce сводится к паре моментов:
Проверка размера сообщения относительно схемы. В отличие от питоновского скрипта, где сверялся размер пакета на допустимый максимум, в адаптере происходит сверка размера относительно заданного размера сообщения. Иными словами, размер сообщения может быть выставлен в 180 байт и если пакет вдруг станет 181 или 179 байт - адаптер его не примет.
Дополнительная случайная задержка перед отправлением. При отправлении сообщений в одно время через LoRa радиомодуль может произойти коллизия из-за которой пакет просто не дойдёт до своего получателя. Время измеряется миллисекундами, что при ручном отправлении вполне допустимо. Но когда мы говорим про автоматическое отправление раз в какой-то период - это уже другое дело. Тайминги могут вполне легко совпасть и отправляемый пакет просто пропадёт. Для этого я дополнительно ввожу случайную задержку от нуля до пары минут.
func (p *sMeshtasticAdapter) Produce(pCtx context.Context, pNetMsg layer1.IMessage) error { msgLen := p.fSettings.GetAdapterSettings().GetMessageSizeBytes() + layer1.CMessageHeadSize msgGotLen := len(pNetMsg.ToBytes()) // ... log builder if uint64(msgGotLen) != msgLen { return ErrInvalidMessageSize } // ... check hash message delay := time.Duration(0) if maxDelay := p.fSettings.GetMaxDelayTime().Milliseconds(); maxDelay != 0 { v := random.NewRandom().GetUint64() % uint64(maxDelay) delay = time.Duration(v) * time.Millisecond } timer := time.NewTimer(delay) defer timer.Stop() select { case <-pCtx.Done(): return pCtx.Err() case <-timer.C: } // ... do POST request: pNetMsg.GetBody() return nil }
В этом коде присутствует layer1.CMessageHeadSize константа, которая отвечает за второй этап шифрования (или первый слой шифрования относительно принимающей стороны). Это значение нужно только для совместимости с HLK, при вызове pNetMsg.GetBody() данный слой удаляется, оставляя только второй слой шифрования.
При получении сообщений используется логика ожидания поступающих пакетов, где раз в N секунд адаптер запрашивает у питоновского сервиса сообщения.
func (p *sMeshtasticAdapter) Consume(pCtx context.Context) (layer1.IMessage, error) { select { case <-pCtx.Done(): return nil, pCtx.Err() case msg := <-p.fNetMsgChan: return msg, nil } } func (p *sMeshtasticAdapter) runSubscriber(pCtx context.Context) error { msgSize := p.fSettings.GetAdapterSettings().GetMessageSizeBytes() ticker := time.NewTicker(p.fSettings.GetWatchPeriod()) defer ticker.Stop() for { select { case <-pCtx.Done(): return pCtx.Err() case <-ticker.C: // ... do GET request: get msgs for _, v := range msgs { switch { case v.FChannel != p.fSettings.GetChannel(): continue case uint64(len(v.FMessage)) != msgSize: continue } // ... pack message to HLK msg := layer1.NewMessage( layer1.NewConstructSettings(&layer1.SConstructSettings{ FSettings: p.fSettings.GetAdapterSettings(), }), v.FMessage, ) // ... log builder // ... save hash message into cache if ok := p.pushMessageToChan(msg); !ok { continue } } } } } func (p *sMeshtasticAdapter) pushMessageToChan(pMsg layer1.IMessage) bool { select { case p.fNetMsgChan <- pMsg: return true default: return false } }
И на этом в принципе всё. Скрипт сервиса написан, адаптер под сервис также написан. Осталось лишь протестировать работоспособность. Ознакомиться с полным кодом адаптера можно тут.
Проверка работоспособности
Чтобы локально проверить работоспособность - потребуется две схемы Meshtastic/LoRa, два USB-порта и два USB-Type-C кабеля с возможностью передачи данных. Для +- быстрой проверки я подготовил docker-compose файл при тесте echo-сервиса.
$ git clone https://github.com/number571/hidden-lake $ cd hidden-lake/examples/echo_service/routing/hla_meshtastic $ make
$ cd hidden-lake/examples/echo_service $ make request

Чтобы проверить сеть в более нормальном варианте - нужно установить HLC (composite). Его можно установить из релизов, можно при помощи инструментов Go: go install github.com/number571/hidden-lake/cmd/hlc@latest. Также для графического интерфейса нужно будет установить HL-Client. Его также можно установить из релизов, либо при помощи Go: go install github.com/number571/hl-client@latest.
$ mkdir node && cd node $ hlc --network std-internal-meshtastic
$ hl-client
Для запуска второй ноды можно использовать этот же компьютер, но нужно будет дополнительно поменять в конфигурационных файлах .yml порты, чтобы не было конфликтов. Также в hla-meshtastic.yml нужно указать точный путь к девайсу. По умолчанию работает дефолтная настройка с поиском существующих девайсов Meshtastic.
connection: devpath: /dev/ttyUSB1
И также указать HL клиенту к каким адресам следует подключаться, как пример:
$ hl-client -k localhost:8572 -m localhost:8591 -p localhost:8551 -f localhost:8541
При успешном запуске нужно установить одинаковый ключ шифрования для обоих клиентов и начать общение...

Заключение
Общением вышеописанное конечно сложно назвать, когда отправление занимает ~3-4 минуты, но главное здесь что оно работает, и не менее важно, что изменённая схема шифрования, которая была написана под требования протокола Meshtastic, даёт также возможность внедрения трафика Hidden Lake в другие системы, не менее требовательные чем Meshtastic/LoRa.
