Введение

В предыдущих своих работах я писал, что анонимную сеть 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-задача

Задача на базе очередей может быть описана следующим списком действий:

  1. Каждое сообщение m шифруется ключом получателя k: c = Ek(m),

  2. Сообщение c отправляется в период = T всем участникам сети,

  3. Период T одного участника независим от периодов T1, T2, ..., Tn других участников,

  4. Если на период T сообщения не существует, то в сеть отправляется ложное сообщение v без получателя (со случайным ключом r): c = Er(v),

  5. Каждый участник пытается расшифровать принятое им сообщение из сети: m = Dk(c).

QB-сеть с тремя узлами A, B, C
QB-сеть с тремя узлами A, B, 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 байт заголовков не будут участвовать в передаче. Теперь же, что касается первого этапа шифрования - он выглядит следующим образом:

E_{(privA, pubB)}(m) = (E_{pubB}(k')\text{ || }E_{k'}(H(pubA)\text{ || }s\text{ || }m'\text{ || }h\text{ || }S_{privA}(h))),h = H_{MAC(s)}(pubA \text{ || } pubB \text{ || } m'), m' = f(m), k' = [RNG], s = [RNG]

где k' – сеансовый ключ шифрования, рассчитанный на одно сообщение, s – криптографическая соль, рассчитанная на одно сообщение, m – открытое сообщение, pubA, pubB – публичные ключи участников A, B соответственно, privA – приватный ключ участника A, h – результат хеширования, S – функция подписания, f – функция дополнения сообщения до константной величины, H – функция хеширования, HMAC – функция вычисления имитовставки на базе хеш-функции H. В этой схеме предполагается, что A – есть отправитель информации m, B – есть получатель данной информации.

В общей сумме, самыми ресурсными элементами здесь являются KEM:E_{pubB}(k') и DSA: S_{privA}(h), которые и занимают ~4KiB (1088 KEM + 3309 DSA = 4397). Всё остальное занимает 172 байт, что тоже не очень хорошо, когда у нас лимит всего в 200 байт. И это ещё без учёта накладных расходов на работу прикладных сервисов самой Hidden Lake. Таким образом не получится просто заменить KEM на X25519 или PSK (Pre-Shared key), а подпись на ED25519 или HMAC от ключа абонента - размер уже будет стремиться или превышать 200 байт. Таким образом, необходимо заменить данную схему полностью на более минималистичную. Также, не менее важным условием является сохранение устойчивости сети к постквантовой криптографии, из-за чего нельзя применять асимметричные алгоритмы на эллиптических кривых (которые так и манят малым размером ключей).

В результате, была создана следующая схема:

E_{k}(m) = (s \text{ || } E_{k'}^{AE}(m')),k' = KDF(k, s), m' = f(m), s = [RNG]

где 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 и так умеет шифровать сообщения? У меня есть несколько аргументов за дополнительное шифрование:

  1. Не Meshtastic'ом едины. Данную функцию шифрования можно применять не только в среде Meshtastic/LoRa, но и в любой другой среде, в том числе на каких-нибудь публичных централизованных сервисах, где шифрования по умолчанию не существует,

  2. Для личных сообщений Meshtastic использует асимметричный алгоритм X25519, который будет уязвим при постквантовом периоде. Его взлом будет гарантировать прошлую и дальнейшую дешифровку сообщений,

  3. Не были ли внедрены в 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 сводится к паре моментов:

  1. Проверка размера сообщения относительно схемы. В отличие от питоновского скрипта, где сверялся размер пакета на допустимый максимум, в адаптере происходит сверка размера относительно заданного размера сообщения. Иными словами, размер сообщения может быть выставлен в 180 байт и если пакет вдруг станет 181 или 179 байт - адаптер его не примет.

  2. Дополнительная случайная задержка перед отправлением. При отправлении сообщений в одно время через 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.