Meshtastic: a native mesh-header + AES-CTR + protobuf decoder/encoder¶
A pure-Python Meshtastic application layer — the 16-byte on-air header, an in-repo AES-128/256 (FIPS-197-verified) CTR cipher, the channel-hash key hint, and a hand-rolled protobuf reader — riding on the Semtech LoRa PHY.
Rafe project · app/radio/lorasdr/meshtastic.py · constants transcribed from the
Meshtastic firmware + protobufs · PHY-validated, on-air interop pending
Abstract¶
Meshtastic is the LoRa mesh protocol that actually populates the 433/868/915 MHz
hobby bands, so once Rafe could demodulate a Semtech LoRa frame off the LimeSDR it
was a small, self-contained step to read the content of that traffic. This module
is that step: it takes the decoded LoRa payload bytes and turns them into a mesh
packet plus interpreted application data — text messages, GPS positions, node-info
names — with zero external dependencies. It parses the fixed 16-byte on-air
PacketHeader, filters by the 1-byte channel-hash hint exactly as the firmware
does, decrypts the body with AES-CTR implemented from scratch in pure Python
(S-box synthesised from the GF(2⁸) inverse + affine map, key schedule, block
cipher — all validated against the FIPS-197 known-answer vectors), then hand-parses
the Data protobuf and decodes the common port numbers. The public LongFast
default key (d4f1bb3a…6901) is built in, so public mesh traffic is readable out
of the box; user PSKs (1-byte selectors or raw 16/32-byte keys) cover private
channels. Every constant — the header bit-layout, the nonce construction, the
default key, the PortNum table, the channel-hash function, the on-air sync word
0x2b and preamble 16 — is transcribed from the Meshtastic firmware
(CryptoEngine / Channels / RadioInterface) and the meshtastic/protobufs,
i.e. protocol facts rather than design choices. It is validated by FIPS-197 AES,
text/position/nodeinfo round-trips, and a full IQ → LoRa → Meshtastic-text decode
through the SDR chain with a carrier offset applied.
1. Motivation¶
The LoRa subsystem (app/radio/lorasdr/, see lora-phy.md and
lora-modem.md) gives Rafe a Semtech-interoperable LoRa
transceiver: chirp IQ in, symbols out, symbols → bytes via the PHY codec. But a raw
LoRa frame is just an opaque payload. The overwhelming majority of what is on the
air in the ISM bands is Meshtastic, which layers its own tiny encrypted
application protocol on top of that payload. Decoding LoRa without decoding
Meshtastic is like demodulating FSK without knowing it carries RTTY — you have the
bits but not the message.
Meshtastic's app layer is deliberately small: a fixed-size binary header, a single AES-CTR encryption of a protobuf blob, and a handful of well-known port numbers. It is entirely reimplementable from published firmware constants. The only mild surprise is that doing so with no crypto dependency means writing AES in Python — which, done carefully and pinned to FIPS-197 vectors, is exactly in the spirit of the rest of this project (native FT8, native RVQ voice, native LoRa PHY): no build dependencies, full control, something genuinely ours.
2. Background¶
2.1 Meshtastic is LoRa PHY + a thin encrypted app layer¶
A Meshtastic transmission is an ordinary Semtech LoRa frame. Everything below the
payload — chirp spread-spectrum modulation, preamble, sync word, explicit header,
Hamming FEC, whitening, CRC-16 — is standard LoRa and is handled by
lora-phy.md (symbol↔byte codec) and
lora-modem.md (chirp IQ modem + CFO/STO sync). What Meshtastic
adds, inside the LoRa payload, is:
LoRa payload bytes
└─ 16-byte PacketHeader (to / from / id / flags / channel-hash / hops)
└─ AES-CTR-encrypted body
└─ Data protobuf (portnum, payload, …)
└─ application payload (UTF-8 text / Position / User / …)
This module (meshtastic.py) is precisely that inside part: header parse →
channel-hash filter → AES-CTR decrypt → protobuf parse → port interpret. It sits
directly on top of the PHY; sdr.py's LoRaFramer calls meshtastic.decode() on
each decoded LoRa payload when run with --meshtastic
(meshtastic.py, sdr.py).
2.2 Distinct from LoRaWAN¶
Meshtastic is not LoRaWAN. They share only the Semtech LoRa PHY. LoRaWAN is a
star-of-stars network with gateways, a network server, MAC commands, ABP/OTAA join
procedures, per-device AES-128 session keys (NwkSKey/AppSKey) and a 13-byte MAC
header with frame counters; its LoRa sync word is 0x34. Meshtastic is a flat,
server-less, flood-routed mesh: every node relays every packet (bounded by a
hop limit), there is no join, and the encryption is a shared channel pre-shared
key rather than a per-device session key. Meshtastic's LoRa sync word is 0x2b
(meshtastic.py), which alone keeps its frames from being confused with
LoRaWAN (0x34) or generic private LoRa (0x12) at the modem's frame-sync stage.
2.3 Threat model of the "encryption"¶
Meshtastic's default channel key is public and published in the firmware source, so default-channel traffic is encrypted only against casual eavesdropping, not against anyone with the (universally known) key — which is why this decoder can read it. Privacy on Meshtastic comes from configuring a private channel PSK, or from the newer public-key direct-message layer (PKC, out of scope here — see §9).
3. Packet structure¶
3.1 The 16-byte on-air PacketHeader¶
Every Meshtastic LoRa payload begins with a fixed 16-byte header. All multi-byte
integer fields are little-endian (parse_header, meshtastic.py, uses
struct.unpack("<III", raw[:12])). This is the firmware's PacketHeader struct as
serialised by RadioLibInterface.
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 | to |
destination node id (uint32 LE); 0xFFFFFFFF = broadcast |
| 4 | 4 | from |
sender node id (uint32 LE) |
| 8 | 4 | id |
packet id (uint32 LE) — also seeds the crypto nonce |
| 12 | 1 | flags |
bit-packed (below) |
| 13 | 1 | channel_hash |
1-byte channel key/name hint (§4.4) |
| 14 | 1 | next_hop |
next-hop node id (low byte), 0 = flood |
| 15 | 1 | relay_node |
relaying node id (low byte), 0 = none |
The flags byte (meshtastic.py) packs five sub-fields:
| Field | Bits | Extract | Meaning |
|---|---|---|---|
hop_limit |
0–2 | flags & 0x07 |
remaining hops (0–7; 0 = don't relay) |
want_ack |
3 | bool(flags & 0x08) |
sender requests an ACK |
via_mqtt |
4 | bool(flags & 0x10) |
packet traversed an MQTT gateway |
hop_start |
5–7 | (flags >> 5) & 0x07 |
original hop limit at TX (for hop counting) |
Node ids are conventionally rendered two ways, both produced by decode()
(meshtastic.py): the destination as "0x%08x" (e.g. 0xffffffff) and
the source in Meshtastic's user-facing "!%08x" form (e.g. !deadbeef).
3.2 The encrypted body¶
Bytes [16:] of the payload are the ciphertext (enc = raw[16:],
meshtastic.py). They are the AES-CTR encryption (§4) of a serialised
Data protobuf (§5). Because AES-CTR is a stream cipher, ciphertext length ==
plaintext length — there is no block padding, no MAC, no auth tag. A worked
example, the 6-byte body of a "Hi" text on the default channel:
plaintext Data protobuf: 08 01 12 02 48 69 ("Hi", portnum 1)
ciphertext (AES-CTR, key = default, id/from as below): 1e a0 5f b2 e7 49
with the accompanying 16-byte header (broadcast, from !deadbeef, id 0x0a0b0c0d,
hop_limit 3, channel-hash 0x02):
4. Crypto¶
4.1 AES-128 / AES-256, pure Python¶
AES is implemented in meshtastic.py with no dependency, then verified
against the FIPS-197 known-answer vectors (§8). Meshtastic uses AES-128 for 16-byte
keys and AES-256 for 32-byte keys; the same code handles both because it is driven
by key length: nk = len(key)//4 32-bit words, rounds = nk + 6 → 10 rounds
for AES-128, 14 for AES-256 (meshtastic.py).
S-box synthesis (_make_sbox, meshtastic.py). The S-box is computed,
not tabulated. For each byte a, its multiplicative inverse in GF(2⁸) is found by
brute-force search (inv[0] = 0 by construction), where GF multiplication
(_gf_mul, meshtastic.py) is the Russian-peasant product reduced modulo the
AES field polynomial x⁸+x⁴+x³+x+1 (reduction constant 0x1B). The standard
AES affine transformation is then applied to the inverse x:
implemented as s ^= ((x << i) | (x >> (8-i))) & 0xFF for i = 1..4, finished with
^ 0x63 (meshtastic.py). This yields the canonical AES S-box (S(0)=0x63,
etc.).
Round constants (_RCON, meshtastic.py).
Key schedule (AES.__init__, meshtastic.py). Standard Rijndael
expansion into 4·(rounds+1) words. For each new word w[i]: t = w[i-1]; when
i % nk == 0, rotate t left one byte, SubWord it, and XOR RCON[i/nk − 1] into
byte 0; additionally for AES-256 (nk > 6) when i % nk == 4, SubWord t
(no rotate); then w[i] = w[i−nk] ⊕ t.
Block cipher (encrypt_block, meshtastic.py). The 16-byte block is
loaded column-major into a 4×4 state (s[r][c] = blk[r + 4c]), then:
AddRoundKey(0);- rounds
1 … rounds−1:SubBytes→ShiftRows→MixColumns→AddRoundKey; - final round:
SubBytes→ShiftRows→AddRoundKey(rounds)(noMixColumns); - serialise the state column-major back to 16 bytes.
The transforms are textbook: _sub maps every cell through _SBOX; _shift
left-rotates row r by r (s[r] = s[r][r:] + s[r][:r]); _mix is the fixed
GF(2⁸) MixColumns matrix [[2,3,1,1],[1,2,3,1],[1,1,2,3],[3,1,1,2]]; _add_rk
XORs round-key word rk[rnd·4 + c] down column c (meshtastic.py). Only
encryption is implemented — CTR mode needs no inverse cipher.
4.2 AES-CTR mode¶
aes_ctr(key, nonce, data) (meshtastic.py) is NIST SP 800-38A counter
mode. The 16-byte nonce is the initial counter block. It is read as a single
big-endian 128-bit integer, and for each successive 16-byte block the keystream
is AES_encrypt(counter) with counter += 1 (masked to 128 bits) after each block:
ctr = int.from_bytes(nonce, "big")
for each 16-byte block of data:
keystream = AES.encrypt_block( (ctr & (2¹²⁸−1)).to_bytes(16, "big") )
out += data_block ⊕ keystream[:len(block)]
ctr += 1
CTR is symmetric — decrypt == encrypt (both XOR the same keystream), so the same function is used on RX and TX. The final block is truncated to the remaining data length, so the ciphertext is exactly as long as the plaintext.
4.3 The nonce construction¶
The 16-byte nonce is assembled by _nonce(packet_id, from_node)
(meshtastic.py):
nonce = struct.pack("<QI", packet_id, from_node) + b"\0\0\0\0"
└─ packetId, 8 bytes LE ─┘└ fromNode 4 LE ┘└─ 4 zero bytes ─┘
| Bytes | Field | Encoding |
|---|---|---|
| 0–7 | packetId |
64-bit little-endian (the 32-bit header id, zero-extended) |
| 8–11 | fromNode |
32-bit little-endian sender id |
| 12–15 | extraNonce |
four zero bytes |
Worked example (id 0x0a0b0c0d, from 0xdeadbeef):
This mirrors the firmware's CryptoEngine::initNonce(fromNode, packetId,
extraNonce=0), which lays the fields out in exactly this order. Because the four
extraNonce bytes are the trailing (least-significant, in big-endian) bytes of the
counter, the CTR +1 per block increments precisely that region — a sane block
counter that never collides with the packetId/fromNode fields for any realistic
message length. Caveat: for a single AES block (bodies ≤ 16 bytes) the counter
never increments, so the construction is unambiguous; the exact multi-block
increment convention (this NIST big-endian +1 over the full 128-bit block) is
correct by construction but has not been confirmed against a real multi-block
over-the-air capture (§8).
4.4 The default key and PSK-selector expansion¶
Default channel key (DEFAULT_PSK, meshtastic.py) — the public
LongFast key, transcribed from the firmware's CryptoEngine:
expand_psk(psk) (meshtastic.py) resolves whatever a channel supplies
into an actual AES key, reproducing the firmware's Channels::getKey:
| Input length | Result |
|---|---|
| 0 bytes | None — no channel key configured |
| 1 byte, value 0 | None — encryption disabled (plaintext channel) |
| 1 byte, value 1 | the default key unchanged (LongFast) |
| 1 byte, value N | the default key with its last byte set to (0x01 + N − 1) & 0xFF |
| 2–15 bytes | zero-padded on the right to 16 bytes |
| 16 or 32 bytes | used directly (AES-128 / AES-256) |
The 1-byte form is a selector, not a key: it maps small integers onto the
family of "default-ish" keys that differ only in their last byte, which is how
Meshtastic's simple channel presets (1..N) are represented compactly. E.g.
expand_psk([2]) = d4f1bb3a…6902 (last byte 0x01 → 0x02).
4.5 The channel-hash function¶
The header's 1-byte channel_hash is a hint — a cheap way for a receiver to skip
packets it can't decrypt without attempting AES. It is the XOR-fold of the channel
name XORed with the XOR-fold of the (expanded) channel key
(channel_hash, meshtastic.py; _xor_hash, meshtastic.py):
xorHash(b) = b[0] ⊕ b[1] ⊕ … ⊕ b[n-1] (0 for the empty string)
channel_hash = ( xorHash(name_utf8) ⊕ xorHash(key) ) & 0xFF
reproducing the firmware's Channels::generateHash. One firmware quirk is honoured
(meshtastic.py): the channel name "Default" is treated as the empty
string "", so the default channel hashes on its key alone. For the built-in
default key this evaluates to a concrete, checkable value:
xorHash("") = 0x00
xorHash(DEFAULT_PSK) = 0x02
channel_hash("Default", DEFAULT_PSK) = 0x00 ⊕ 0x02 = 0x02
(Verifiable by hand from the 16 key bytes.) A channel whose name is literally
"LongFast" instead hashes to 0x08; decode() accepts both 0x02 and
0x08 by default (see §5.1) so the public channel decodes either way.
5. Application layer¶
5.1 The Data protobuf wire format¶
The decrypted body is a Meshtastic Data message (meshtastic/mesh.proto), read
by a minimal, bounds-safe protobuf reader (_read_varint,
meshtastic.py; _fields, meshtastic.py). The reader yields
(field_number, wire_type, value) and stops cleanly on any malformed structure —
essential because a wrong-key AES-CTR decrypt produces random bytes, and the reader
must degrade to "no fields" rather than crash or hallucinate. Wire types handled:
| Wire type | Name | Decoding |
|---|---|---|
| 0 | varint | LEB128, up to 64 bits |
| 2 | length-delimited | varint length then that many raw bytes (bounds-checked) |
| 5 | 32-bit | 4 raw bytes (fixed32/sfixed32/float) |
| 1 | 64-bit | 8 raw bytes (fixed64/double) |
Each protobuf tag byte is (field_number << 3) | wire_type. The Data fields
parsed (parse_data, meshtastic.py):
| Field # | Wire | Name | Handled as |
|---|---|---|---|
| 1 | 0 (varint) | portnum |
PortNum enum (§5.2) |
| 2 | 2 (bytes) | payload |
application payload |
| 3 | 0 (varint) | want_response |
bool |
| 5 | 5 (fixed32) | source |
uint32 LE |
| 7 | 5 (fixed32) | reply_id |
uint32 LE |
Defaults are portnum = 0, payload = b"". (The wire schema also defines
dest=4, request_id=6, emoji=8 fixed32 fields, which this reader simply
ignores.)
5.2 The PortNum table¶
portnum selects the application. The table (PORTNUMS, meshtastic.py),
transcribed from meshtastic/portnums.proto:
| # | Name | # | Name |
|---|---|---|---|
| 0 | UNKNOWN | 32 | REPLY |
| 1 | TEXT_MESSAGE | 34 | PAXCOUNTER |
| 2 | REMOTE_HARDWARE | 64 | SERIAL |
| 3 | POSITION | 65 | STORE_FORWARD |
| 4 | NODEINFO | 66 | RANGE_TEST |
| 5 | ROUTING | 67 | TELEMETRY |
| 6 | ADMIN | 70 | TRACEROUTE |
| 7 | TEXT_MESSAGE_COMPRESSED | 71 | NEIGHBORINFO |
| 8 | WAYPOINT | 73 | MAP_REPORT |
| 9 | AUDIO | ||
| 10 | DETECTION_SENSOR |
Unknown numbers are named PORT_<n> (meshtastic.py).
5.3 Interpreting text / position / nodeinfo¶
_interpret(portnum, payload) (meshtastic.py) best-effort-decodes the
three most common ports; anything else returns just the port name and an empty info
dict. All decoding is wrapped so a malformed inner payload never raises.
-
TEXT_MESSAGE (1). Payload is raw UTF-8;
info["text"] = payload.decode("utf-8", "replace")(meshtastic.py). -
POSITION (3). Payload is a
Positionsub-protobuf (meshtastic.py): field 1 (fixed32, sfixed32latitude_i) →lat = int32 · 1e-7degrees; field 2 (fixed32longitude_i) →lon = int32 · 1e-7; field 3 (varintaltitude) →altmetres. The1e-7scale is Meshtastic's fixed-point degrees convention. -
NODEINFO (4). Payload is a
Usersub-protobuf (meshtastic.py): field 1 (string) →id(e.g.!deadbeef), field 2 (string) →long_name, field 3 (string) →short_name.
6. Decode and Encode, step by step¶
6.1 Decode (decode, meshtastic.py)¶
Input: one LoRa payload raw; an iterable of PSKs to try (keys, default (1,) =
LongFast default); and candidate channel names (channel_names, default ("Default", "LongFast")).
- Header parse.
parse_header(raw); iflen(raw) < 16, returnNone. Build the output dict withto/from/id/flags/hops plusto_hexandfrom_hex. - Body split.
enc = raw[16:]. - For each candidate key:
a.
key = expand_psk(...)— an integer selector becomesbytes([psk])first (meshtastic.py). b. Channel-hash filter. Compute the set of acceptable hashes for this key over every candidate channel name —wanted = {channel_hash(nm, key) for nm in channel_names}(default names("Default", "LongFast")→{0x02, 0x08}), or{0}if the key isNone. Ifhdr["channel_hash"]is not in that set, skip this key. This is the firmware's own gate: it stops a wrong key from "decrypting" random bytes into a plausible-looking fake port, while still accepting the public channel whether the sender named it "" or "LongFast". c. Decrypt.plain = aes_ctr(key, _nonce(id, from), enc)(orencitself if encryption is disabled). d. Parse + validate.data = parse_data(plain). Accept iffportnumis in the PortNum table and (it isn't TEXT_MESSAGE, or the payload is printable —_printable,meshtastic.py, guards against a garbage decrypt that happens to land on portnum 1). On acceptance, interpret the port, merge{decrypted: True, portnum, port, payload, **info}, and return. - No key worked: set
decrypted = Falseand return the header-only dict.
6.2 Encode / TX (build, meshtastic.py)¶
build(payload_text, from_node, to=0xFFFFFFFF, packet_id=0x12345678, portnum=1,
psk=1, hop_limit=3, channel_name="Default") assembles a complete on-air packet:
- Hand-serialise the
Dataprotobuf (meshtastic.py):(data = 08 <portnum> 12 <len> <payload bytes> │ field 1 │ field 2 (len-delimited) └ tag 0x08 └ tag 0x120x08 = (1<<3)|0,0x12 = (2<<3)|2.) This compact form assumesportnum < 128andlen(payload) < 128so both are single-byte varints — true for text and the test vectors; a longer payload would need multi-byte length varints. - Encrypt.
key = expand_psk(psk);enc = aes_ctr(key, _nonce(packet_id, from_node), data)(ordataunchanged if the key isNone). - Channel hash.
chash = channel_hash(channel_name, key)(or0). - Header.
struct.pack("<III", to, from_node, packet_id)then the flags bytehop_limit & 0x07(want_ack/via_mqtt/hop_start all 0),chash, andnext_hop = relay_node = 0. - Return
header + enc— ready to hand to the LoRa PHY encoder.
make_id() (meshtastic.py) supplies a random 32-bit packet id from the OS
CSPRNG when transmitting for real.
6.3 Where it plugs into the SDR chain¶
On RX, sdr.py's LoRaFramer(..., meshtastic=True) calls meshtastic.decode(payload,
keys=self.psks) on every LoRa payload it recovers and attaches the result under
pkt["meshtastic"] (sdr.py). On TX, sdr.send_meshtastic() calls
build() then transmits with the Meshtastic sync word / preamble / CR
(sdr.py). The modem sends the sync word 0x2b as two up-chirps at
symbols (0x2·8, 0xB·8) = (16, 88) (modem.py).
7. Constants and tables¶
Default channel key (DEFAULT_PSK, meshtastic.py):
16-byte header layout (little-endian; meshtastic.py):
off: 0 4 8 12 13 14 15
to(u32) from(u32) id(u32) fl ch nh rl
flags byte: [hop_start:3][via_mqtt:1][want_ack:1][hop_limit:3]
16-byte CTR nonce (meshtastic.py):
PortNum values — see §5.2 (meshtastic.py).
On-air framing constants (meshtastic.py), the parameters the LoRa
modem needs to match Meshtastic:
| Constant | Value | Meaning |
|---|---|---|
MESHTASTIC_SYNC |
0x2b |
LoRa sync word (chirp symbols (16, 88)) |
MESHTASTIC_PREAMBLE |
16 |
preamble length in symbols |
MESHTASTIC_CR |
1 |
coding rate 4/5 (TX; RX reads CR from the explicit header) |
Modem presets → (SF, BW) (PRESETS, meshtastic.py). These pick the
LoRa spreading factor and bandwidth; match --sf/--bw to the sender's preset to
demodulate it. Coding rate is read from the LoRa explicit header, so it isn't part
of the preset here.
| Preset | SF | BW (Hz) | Notes |
|---|---|---|---|
| ShortTurbo | 7 | 500 000 | |
| ShortFast | 7 | 250 000 | |
| ShortSlow | 8 | 250 000 | |
| MediumFast | 9 | 250 000 | |
| MediumSlow | 10 | 250 000 | |
| LongFast | 11 | 250 000 | Meshtastic default |
| LongModerate | 11 | 125 000 | LDRO on (symbol > 16 ms) |
| LongSlow | 12 | 125 000 | LDRO on |
(The actual RF centre frequency is regional — EU ≈ 869.525 MHz, US ≈ 906–924 MHz, 70 cm ≈ 433 MHz — and is supplied to the SDR, not encoded in this module.)
8. Interoperability and validation¶
Constants are protocol facts. Every constant above was transcribed from the
Meshtastic firmware (CryptoEngine for the default key + nonce, Channels for the
channel-hash + PSK expansion, RadioInterface/RadioLibInterface for sync word +
preamble) and the meshtastic/protobufs (Data, Position, User, PortNum). They
are not free parameters; they are what the network uses.
FIPS-197 AES (test_meshtastic.py). The pure-Python AES is pinned to the
NIST FIPS-197 known-answer vectors:
| Cipher | Key | Plaintext | Ciphertext |
|---|---|---|---|
| AES-128 | 000102…0f |
00112233…ff |
69c4e0d86a7b0430d8cdb78070b4c55a |
| AES-256 | 000102…1f |
00112233…ff |
8ea2b7ca516745bfeafc49904b496089 |
Key + CTR properties (test_meshtastic.py): expand_psk([1]) ==
DEFAULT_PSK; expand_psk([2])[-1] == DEFAULT_PSK[-1] + 1; and CTR round-trips a
multi-block payload (aes_ctr(k,n, aes_ctr(k,n, data)) == data), exercising the
counter increment.
Application round-trips (test_meshtastic.py): build() → decode()
recovers a text message and the !deadbeef sender id; a POSITION packet round-trips
to lat ≈ 51.5074; and a packet built on a non-default channel (psk=5) is
correctly rejected when only the default key is offered — the channel-hash
filter working as intended.
Full SDR chain (test_meshtastic.py): a Meshtastic text packet is
LoRa-PHY-encoded, chirp-modulated with the real 0x2b sync + preamble 16, resampled,
shifted by a +4 kHz carrier offset, and pushed through LoRaFramer(meshtastic=
True) — recovering "CQ mesh de M0SUP". A separate case drives the TX path
(build_tx_iq) at SF11/BW250k and decodes it back.
Open caveats.
- Multi-block CTR counter (unconfirmed on real capture). The +1 big-endian
increment (§4.3) is correct for single-block bodies unconditionally and is the
standard NIST convention for multi-block; it has been validated in loopback but
not yet against a real multi-block over-the-air Meshtastic packet.
- Default channel names. decode() defaults to channel_names=("Default",
"LongFast"), accepting both channel-hash 0x02 (empty "Default" name) and 0x08
(literal "LongFast" name) on the default key, so the public channel decodes
regardless of which name the sending node stored. For a private channel pass
its exact name(s) in channel_names; a name not in the list is filtered out.
- On-air interop. The definitive test — the prod LimeSDR against a real
Meshtastic node — has not yet been run (see docs/lora-open-issues.md).
9. Limitations¶
- No PKC / direct-message decryption. Newer Meshtastic firmware encrypts direct messages with per-node public-key cryptography (Curve25519 + AES-CCM) rather than the shared channel PSK. This module implements only the channel-PSK AES-CTR path; PKC-encrypted DMs are not decoded.
- No message authentication. AES-CTR provides confidentiality only. There is no MIC/tag to verify, so acceptance relies on the channel-hash hint plus a well-formed protobuf and (for text) a printability check — heuristics, not cryptographic integrity.
- Compact TX encoder.
build()emits single-byte varints forportnumand payload length, so it is limited toportnum < 128and payloads< 128bytes. Full-range TX would need a general varint encoder. - Partial application decode. Only TEXT_MESSAGE, POSITION and NODEINFO are interpreted into fields; TELEMETRY, ROUTING, WAYPOINT, TRACEROUTE, etc. are identified by name only, with the raw payload passed through.
- No routing / mesh behaviour. This is a codec, not a node: it does not relay, deduplicate by packet id, honour hop limits, ACK, or reassemble. It reads and writes single packets.
- Header assumes explicit-header LoRa. The 16-byte header is parsed from the LoRa
payload as delivered by the PHY; the implicit-header LoRa case is a PHY concern
(see
lora-phy.md), not handled here.
None of these are architectural; each is a bounded extension of a working base.
10. References¶
- Meshtastic firmware —
meshtastic/firmware,src/mesh/CryptoEngine.{h,cpp}(default PSK,initNonce, AES-CTR) andsrc/mesh/Channels.cpp(getKey/PSK expansion,generateHash/channel hash). - Meshtastic firmware —
src/mesh/RadioInterface.cpp/RadioLibInterface.cpp(PacketHeaderlayout, LoRa sync word0x2b, preamble, modem presets). - Meshtastic protobufs —
meshtastic/protobufs:meshtastic/mesh.proto(Data,Position,User,MeshPacket) andmeshtastic/portnums.proto(PortNumenum). - NIST FIPS PUB 197, Advanced Encryption Standard (AES) — the cipher, S-box affine transform, key schedule, and the Appendix B/C known-answer vectors used for validation.
- NIST SP 800-38A, Recommendation for Block Cipher Modes of Operation — CTR mode.
- LoRa PHY layer —
lora-phy.md(symbol↔byte codec) andlora-modem.md(chirp IQ modem + CFO/STO sync), on which this application layer rides; overview in../lora.md.