Patrick Lidstone
Self-hosted

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 + 610 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:

  1. AddRoundKey(0);
  2. rounds 1 … rounds−1: SubBytesShiftRowsMixColumnsAddRoundKey;
  3. final round: SubBytesShiftRowsAddRoundKey(rounds) (no MixColumns);
  4. 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 Noneencryption 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 Position sub-protobuf (meshtastic.py): field 1 (fixed32, sfixed32 latitude_i) → lat = int32 · 1e-7 degrees; field 2 (fixed32 longitude_i) → lon = int32 · 1e-7; field 3 (varint altitude) → alt metres. The 1e-7 scale is Meshtastic's fixed-point degrees convention.

  • NODEINFO (4). Payload is a User sub-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")).

  1. Header parse. parse_header(raw); if len(raw) < 16, return None. Build the output dict with to/from/id/flags/hops plus to_hex and from_hex.
  2. Body split. enc = raw[16:].
  3. For each candidate key: a. key = expand_psk(...) — an integer selector becomes bytes([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 is None. If hdr["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) (or enc itself if encryption is disabled). d. Parse + validate. data = parse_data(plain). Accept iff portnum is 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.
  4. No key worked: set decrypted = False and 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:

  1. Hand-serialise the Data protobuf (meshtastic.py):
    data = 08 <portnum>  12 <len>  <payload bytes>
           │  field 1    │  field 2 (len-delimited)
           └ tag 0x08    └ tag 0x12
    (0x08 = (1<<3)|0, 0x12 = (2<<3)|2.) This compact form assumes portnum < 128 and len(payload) < 128 so both are single-byte varints — true for text and the test vectors; a longer payload would need multi-byte length varints.
  2. Encrypt. key = expand_psk(psk); enc = aes_ctr(key, _nonce(packet_id, from_node), data) (or data unchanged if the key is None).
  3. Channel hash. chash = channel_hash(channel_name, key) (or 0).
  4. Header. struct.pack("<III", to, from_node, packet_id) then the flags byte hop_limit & 0x07 (want_ack/via_mqtt/hop_start all 0), chash, and next_hop = relay_node = 0.
  5. 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 +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 for portnum and payload length, so it is limited to portnum < 128 and payloads < 128 bytes. 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

  1. Meshtastic firmwaremeshtastic/firmware, src/mesh/CryptoEngine.{h,cpp} (default PSK, initNonce, AES-CTR) and src/mesh/Channels.cpp (getKey/PSK expansion, generateHash/channel hash).
  2. Meshtastic firmwaresrc/mesh/RadioInterface.cpp / RadioLibInterface.cpp (PacketHeader layout, LoRa sync word 0x2b, preamble, modem presets).
  3. Meshtastic protobufsmeshtastic/protobufs: meshtastic/mesh.proto (Data, Position, User, MeshPacket) and meshtastic/portnums.proto (PortNum enum).
  4. 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.
  5. NIST SP 800-38A, Recommendation for Block Cipher Modes of Operation — CTR mode.
  6. LoRa PHY layerlora-phy.md (symbol↔︎byte codec) and lora-modem.md (chirp IQ modem + CFO/STO sync), on which this application layer rides; overview in ../lora.md.