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:
bit 7 6 5 4 3 2 1 0
└── hop_start ──┘ via_mqtt want_ack └── hop_limit ──┘
| 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):
ff ff ff ff ef be ad de 0d 0c 0b 0a 03 02 00 00
└─ to ────┘ └─ from ───┘ └── id ───┘ fl ch nh rl
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:
s = x ⊕ ROTL₁(x) ⊕ ROTL₂(x) ⊕ ROTL₃(x) ⊕ ROTL₄(x) ⊕ 0x63
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).
01 02 04 08 10 20 40 80 1B 36 6C D8 AB 4D
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):
0d 0c 0b 0a 00 00 00 00 ef be ad de 00 00 00 00
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:
d4 f1 bb 3a 20 29 07 59 f0 bc ff ab cf 4e 69 01 (16 bytes, AES-128)
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):
d4 f1 bb 3a 20 29 07 59 f0 bc ff ab cf 4e 69 01
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):
[ packetId : 8 bytes LE ][ fromNode : 4 bytes LE ][ 0 0 0 0 ] → big-endian counter
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
+1big-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 tochannel_names=("Default", "LongFast"), accepting both channel-hash0x02(empty "Default" name) and0x08(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) inchannel_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.