Meshtastic
Project Meshtastic | |
---|---|
![]() | |
Experiments with Meshtastic | |
Status | In progress |
Contact | bertrik |
Last Update | 2025-09-01 |
Intro
The page describes the research done to figure out if Meshtastic can be used to transfer citizen science measurement data.
The proposition of what I want to achieve:
- a citizen science data sensor sends measurement data into the meshtastic network
- at some point the data reaches a meshtastic node that is connected to the internet, which forwards it over MQTT
- a central listener process picks up the data from the internet and processes it further (e.g. forward it to sensor.community)
Stuff to research:
- how extensive is the network, see meshnet.nl coverage map
- how reliable is the network:
- Nodes have a wide range of technical configurations, however the majority of nodes appear to use the following:
- LongFast channel with encryption key '1' is used at 869.525 MHz
- Internet-connected nodes in the Netherlands are usually connected over MQTT at mqtt.meshnet.nl
- a fraction of about 1-in-5 nodes appears to be connected to internet
- hop limit of 3, this is the default
- most nodes are in CLIENT mode (this is good)
- Nodes have a wide range of technical configurations, however the majority of nodes appear to use the following:
Other info:
- My home node: https://db.meshnet.nl/da639b54.html
- My t1000-e tracker on meshnet: https://db.meshnet.nl/dc427970.html
- My t1000-e tracker: https://vanheusden.com/meshtastic/map/view.php?who=dc427970
TODO
- create a particulate matter sensor that sends meshtastic
- write a backend/plugin for my sensor-data-bridge
Meshcore
- web flasher https://flasher.meshcore.co.uk/
- meshcore companion app https://meshcore.nz/
- meshcore map https://meshcore.co.uk/map.html
- meshcore information https://meshcore.co.uk/
Design
Overall design
- citizen science nodes send data in meshtastic-compatible format, so packets can be routed accross the meshtastic network
- data is sent from the node typically every 5 minutes
- data is broadcast inside the network, until it reaches a node with a MQTT backend connection, typically within a maximum of 3 hops.
- data arriving at the MQTT server is picked up by a backend application, which can then process it further
Protocol design
- Use the common LongFast channel, with the default key, this makes sure that other nodes understand our message and are able to forward it over MQTT
- Follow the the MeshPacket structure, wrap our citizen science playload in a protobuf with specific portnr (PRIVATE_APP = 256), with the "OK-to-MQTT" bit enabled.
- Citizen science payload has its own custom encoding, as usual, so it is basically opaque to meshtastic, just a byte array
- A 32-bit checksum message authentication code (MAC) allows us to verify that it really is our citizen science data packet after decryption
- Each packet already has a semi-unique packet id. Combined with the node id, we consider this unique, so we can identify duplicates at the backend
Checksums
The idea is to send the payload 'unencrypted', e.g. not with an additional layer of encryption on top of meshtastic LongFast encryption, but to add a kind of checksum that authenticates the message.
How LoRaWAN does this, is by prefixing the information payload with a 16-byte array containing context-data (e.g. sender, fport, fcnt, up/down), then calculating a CMAC-AES over it with the network key, then using 4 bytes of the result as MAC.
In python this could look like this:
from Crypto.Hash import CMAC from Crypto.Cipher import AES cmac = CMAC.new(key, ciphermod=AES) cmac.update(message) # Get full CMAC result (16 bytes) cmac_result = cmac.digest() # LoRaWAN uses only the first 4 bytes as the MIC mic = cmac_result[:4]
How to do this in Arduino, chatgpt suggests:
#include <Crypto.h> #include <AES.h> #include <AESCMAC.h> AES128 aes; // AES block cipher (128-bit key) AESCMAC cmac(aes); // CMAC using AES cmac.clear(); cmac.setKey(key, sizeof(key)); cmac.add(message, sizeof(message)); cmac.finalize(cmac_result, sizeof(cmac_result));
Backend
- The backend application listens on a topic on the de-facto central MQTT server for the netherlands, used by most meshtastic nodes, which is mqtt.meshnet.nl
- Decoding works as follows:
- Decrypt with the pre-shared-key (this always work but might result in garbage)
- Attempt to decode according to the the protobuf portnum+payload (meshtastic 'Data')
- Check the port number
- Check and remove checksum/MAC in the payload
- Process the citizen science data payload
- If all of the steps above check out, consider it to be a valid packet
- Check in a local cache if this is a duplicate packet and if so, ignore it
- process citizen science data payload: there is no such thing as TTN attributes, so any data required for further forwarding need to be kept locally (e.g. login credentials for opensense / sensor.community)
Packet structure
On the LoRa radio level:
[ 16-symbol preamble | lora explicit header | lora payload ]
Within the lora payload:
[ meshtastic 16-byte header | meshtastic private-data protobuf ]
The protobuf is encrypted using key "AQ==" and a nonce derived from data in the 16-byte header.
Within the meshtastic protobuf:
[ portnum | private-data | flag ]
The portnum indicates 'private data'. The private-data is not encrypted separately. The flag contains the ok-to-mqtt bit.
Within the private-data:
[ 4-byte message authentication code | citizen-science payload ]
With respect to security coding:
- The payload is *not* encrypted, but it prefixed by a message authentication code (MAC), to be determined exactly what algorithm
- The MAC is only 32-bit, to keep the length manageable
Could be something like (python):
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce, mac_len=4) # 4-byte auth tag ciphertext, auth_tag = cipher.encrypt_and_digest(message)
or (ESP32):
mbedtls_gcm_context gcm; mbedtls_gcm_init(&gcm); mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, key, 128); // 128-bit AES mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT, sizeof(plaintext), nonce, sizeof(nonce), NULL, 0, plaintext, ciphertext, sizeof(auth_tag), auth_tag);
Protocol
See https://meshtastic.org/docs/overview/mesh-algo/
Quick links:
- radio parameters, for LongFast
- Frequency: 869.525 MHz
- LongFast = SF11BW250, CR 4/5, 16 symbols preamble, explicit header, CRC on
- sync word = 0x2B ("to be")
- typically about 500 ms of transmission time for a packet of about 40 bytes total
- data structure:
- unencrypted header https://meshtastic.org/docs/overview/mesh-algo/#layer-1-unreliable-zero-hop-messaging
- encrypted protobuf with https://buf.build/meshtastic/protobufs/docs/main:meshtastic#meshtastic.Data
- Over MQTT: Service Envelope, wraps a MeshPacket: https://buf.build/meshtastic/protobufs/docs/main:meshtastic#meshtastic.ServiceEnvelope
- Over MQTT: MeshPacket https://buf.build/meshtastic/protobufs/docs/main:meshtastic#meshtastic.MeshPacket loosely based on the message as sent over-the-air
- Port numbers: https://buf.build/meshtastic/protobufs/docs/main:meshtastic#meshtastic.PortNum
- MQTT topic organization: https://meshtastic.org/docs/software/integrations/mqtt/#mqtt-topics
- decryption code used on the liam cottle map: https://github.com/liamcottle/meshtastic-map/blob/master/src/mqtt.js#L632
- Meshtastic encryption https://meshtastic.org/docs/overview/encryption/ This is very incomplete! Details need to be reverse-engineered from the code!
Software
I wrote python scripts to interact with the MQTT server and arduino code to run on a LoRa capable ESP32 microcontroller.
See https://github.com/bertrik/mesh-backend
Hardware
Nice antenna? https://nl.aliexpress.com/item/1005007301116616.html
MQTT
In the netherlands, data is typically sent to the 'meshnet.nl' MQTT server, for example
mosquitto_sub -h mqtt.meshnet.nl -u boreft -P meshboreft -t "#" -v
Examples of typical data:
msh/7460-7463/2/stat/!da5857c0 online msh/EU_868/NL/2/e/LongFast/!eb66115c �%]�g(=���gx�� H5��Aw=]�gE��H`���������LongFast␦
Topics with data on MQTT have the following structure:
msh/REGION/2/e/CHANNELNAME/USERID
Sending messages
Requirements for sending mqtt downlinks:
- the meshtastic node needs to have a channel named "mqtt" (exactly), see https://github.com/meshtastic/firmware/blob/master/src/mqtt/MQTT.cpp#L354
- the meshtastic node has JSON be enabled in its MQTT settings
- -> the meshtastic node listens on topic: "ROOT/2/json/mqtt/+", where ROOT = "msh/gouda" in my case
- -> the mqtt publisher sends to topic: 'msh/gouda/2/json/mqtt/!da639b54' for example
- example payload:
{"from": 3663960916, "type": "sendtext", "payload": "Test"}'
Example data
Examples of data as decoded from MQTT using the meshtastic python service wrapper:
packet { from: 2732702784 to: 4294967295 decoded { portnum: POSITION_APP payload: "\r\224\234\024\037\025\303\266\233\002\030\n\270\001 " } id: 663882246 rx_time: 1741511999 rx_snr: -18 hop_limit: 2 rx_rssi: -128 hop_start: 3 } channel_id: "LongFast" gateway_id: "!da544e50"
Packet with encrypted data:
packet { from: 1128181476 to: 4294967295 channel: 8 encrypted: "\007\355{o\340e\352\221\204\3112\365h\304[0\321&\351^{]\264\334\373\320\313>\213\2635\023\345'" id: 4272151039 rx_time: 1741512321 rx_snr: 5.75 hop_limit: 4 rx_rssi: -83 hop_start: 5 } channel_id: "LongFast" gateway_id: "!da5c87d4"