MeshCore Protocol Specification
Section 0: Overview
Spec version: latest (main) · see
Spec Versions for the full list of frozen releases.
Status and Provenance
This is an unofficial, independent specification. It is not part of the upstream MeshCore project, not affiliated with or endorsed by its maintainers, and not authored by them. Read this section before relying on the spec for anything load-bearing.
This document was primarily written by an AI (Claude, by Anthropic — mostly the Opus 4.x family) under the direction of SWaits, with human proofreading and editing on top. Substantive edits — additions, audit passes, errata fixes — are produced through model-driven analysis of the upstream MeshCore C++ source and reviewed before commit.
The upstream MeshCore C++ firmware is the source of truth for all protocol behavior. This spec is downstream and derivative. Whenever the spec and the upstream C++ disagree, upstream wins — and a fix to this spec is filed against this repo, not against MeshCore. Each substantive change in CHANGELOG.md records the upstream commit hash it was validated against.
Refinements have also been driven in part by observations from building DongLoRa firmware and apps against live MeshCore deployments. Where DongLoRa behavior diverges from upstream, this spec follows upstream and treats the DongLoRa side as a follow-up bug, not a spec change.
Conformance against the test corpus is a useful signal but not a substitute for cross-checking against upstream firmware. If you find a divergence from upstream, please file an issue — that is exactly the kind of report this project exists to catch.
Spec Versions
The published site preserves prior spec versions alongside the rolling
latest build. The URL layout is:
https://swaits.github.io/meshcore-spec/latest/— built from the tip ofmain. May change between any two visits.https://swaits.github.io/meshcore-spec/v0.1.0/(and likewise for any futurevX.Y.Z) — a frozen content snapshot. Suitable for citing or for certifying an implementation against.
Frozen versions live as their own directory in the repo
(versions/vX.Y.Z/) — a literal copy of src/ taken at release time.
This deliberately separates content (versioned, frozen) from site
infrastructure (theme, picker, build pipeline; lives at the repo root
and is unversioned). Every deploy rebuilds every published version using
today’s infrastructure, so site improvements show up everywhere without
backporting.
The version-picker in the top-right of every page lists every published version; switching keeps you on the same chapter where it exists in the target version. Spec versions use semver and are independent of the upstream MeshCore firmware’s own versioning — each release records the upstream commit hash it was validated against in CHANGELOG.md.
Scope
This specification covers:
- The packet wire format (framing, header, path, payload)
- All payload types and their binary encodings
- Cryptographic operations (encryption, signing, key exchange, hashing)
- The BLE/Serial companion protocol
- The KISS modem protocol
- The bridge protocol (RS232/ESP-NOW)
It does NOT cover:
- Application-level behavior (e.g., room server logic, repeater policies)
- Radio-layer parameters (LoRa spreading factor, bandwidth, frequency)
- Duty cycle management or transmission scheduling
- User interface or companion app behavior
Terminology
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Additional terms used throughout:
| Term | Definition |
|---|---|
| Packet | The fundamental transmission unit in MeshCore. |
| Header | The first byte of a packet, encoding route type, payload type, and protocol version. |
| Path | A sequence of node hashes representing the route a packet has taken or should follow. |
| Hash | A truncated prefix of a node’s Ed25519 public key, used for routing. |
| Payload | The data portion of a packet, whose structure depends on the payload type. |
| Transport Codes | Optional 4-byte field present in transport-mode packets, used for regional scoping. |
| MAC | Message Authentication Code — a 2-byte truncated HMAC-SHA256 used for integrity verification. |
| MTU | Maximum Transmission Unit — 255 bytes for MeshCore. |
| Flood Routing | Routing mode where packets are broadcast and repeated by intermediate nodes, building up the path as they travel. |
| Direct Routing | Routing mode where the path is supplied by the sender and the packet follows a specific route. |
Byte Order
All multi-byte integer fields in MeshCore are encoded in little-endian byte order unless explicitly stated otherwise. The one exception is CayenneLPP sensor data, which uses big-endian encoding.
Notation
- Binary data is shown as hex bytes separated by spaces:
0D 00 EF BE AD DE - Bit fields are shown in binary with MSB first:
0bVVPPPPRR - Bit numbering is LSB = bit 0, MSB = bit 7
- Field sizes are in bytes unless noted as bits
[field(N)]denotes a field of N bytes[field(N)?]denotes an optional field of N bytes
Protocol Constants
These constants define the fundamental limits of the protocol:
| Constant | Value | Description |
|---|---|---|
MAX_TRANS_UNIT | 255 | Maximum packet size on the wire (bytes) |
MAX_PACKET_PAYLOAD | 184 | Maximum payload size (bytes) |
MAX_PATH_SIZE | 64 | Maximum path size (bytes) |
PUB_KEY_SIZE | 32 | Ed25519 public key size (bytes) |
PRV_KEY_SIZE | 64 | Ed25519 private key size (bytes) |
SIGNATURE_SIZE | 64 | Ed25519 signature size (bytes) |
CIPHER_KEY_SIZE | 16 | AES-128 key size (bytes) |
CIPHER_BLOCK_SIZE | 16 | AES-128 block size (bytes) |
CIPHER_MAC_SIZE | 2 | Truncated HMAC-SHA256 MAC size (bytes) |
PATH_HASH_SIZE | 1 | Default path hash size for v1 (bytes) |
MAX_HASH_SIZE | 8 | Maximum hash size for deduplication (bytes) |
MAX_ADVERT_DATA_SIZE | 32 | Maximum advertisement app data size (bytes) |
Protocol Versions
The protocol version is encoded in bits 6-7 of the header byte:
| Version | Value | Status | Description |
|---|---|---|---|
| V1 | 0x00 | Active | 1-byte src/dest hashes, 2-byte MAC |
| V2 | 0x01 | Reserved | Future (e.g., 2-byte hashes, 4-byte MAC) |
| V3 | 0x02 | Reserved | Future |
| V4 | 0x03 | Reserved | Future |
Currently only V1 is defined. Implementations MUST support V1. Implementations SHOULD reject packets with unrecognized version values unless they have explicit support for that version.
Document Organization
| Section | Title | Description |
|---|---|---|
| 00 | Overview | This document |
| 01 | Wire Format | Packet framing and field layout |
| 02 | Header | Header byte encoding |
| 03 | Path | Path length encoding and path field |
| 04 | Payload: ACK | Acknowledgment payload |
| 05 | Payload: Advertisement | Node advertisement and app data |
| 06 | Payload: Encrypted | REQ/RESPONSE/TXT_MSG payloads |
| 07 | Payload: Anonymous Request | Anonymous request payload |
| 08 | Payload: Group | Group text and data payloads |
| 09 | Payload: Path Return | Encrypted path return payload |
| 10 | Payload: Trace | Path trace payload |
| 11 | Payload: Multipart | Multi-packet payload |
| 12 | Payload: Control | Control and discovery payloads |
| 13 | Payload: Raw Custom | Custom raw payload |
| 14 | Cryptography | AES-128, HMAC-SHA256, encrypt-then-MAC |
| 15 | Identity | Ed25519 keys, ECDH, key hashing |
| 16 | Packet Hash | SHA-256 deduplication hashing |
| 17 | Routing | Flood and direct routing behavior |
| 18 | Companion Protocol | BLE/Serial companion communication |
| 19 | KISS Protocol | KISS modem framing and extensions |
| 20 | Bridge Protocol | RS232/ESP-NOW bridge framing |
References
- MeshCore Firmware — Reference C++ implementation
- MeshCore Documentation — Official protocol documentation
- MeshCore.js — JavaScript implementation
- meshcore-rs — Rust implementation (community)
- RFC 2119 — Key words for requirement levels
- RFC 8032 — Ed25519 digital signatures
- FIPS 197 — AES specification
- FIPS 180-4 — SHA-256 specification
MeshCore Protocol Specification
Section 1: Wire Format
Overview
A MeshCore packet is the fundamental transmission unit. It consists of a 1-byte header, an optional 4-byte transport codes field, a 1-byte path length, a variable-length path, and a variable-length payload. The maximum packet size on the wire is 255 bytes (MAX_TRANS_UNIT).
Packet Layout
0 1 2
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Header | Transport Codes (opt, 4B) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Path Length | Path (variable) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Payload (variable) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
The fields appear in this order on the wire:
[header(1)][transport_codes(4)?][path_len(1)][path(0-64)][payload(1-184)]
Field Summary
| Field | Size | Required | Description |
|---|---|---|---|
| Header | 1 byte | Yes | Route type, payload type, protocol version (see Section 2) |
| Transport Codes | 4 bytes | Conditional | Two uint16_le values; present only when route type is TRANSPORT_FLOOD (0x00) or TRANSPORT_DIRECT (0x03) |
| Path Length | 1 byte | Yes | Encoded hash count and hash size (see Section 3) |
| Path | 0-64 bytes | Yes | Sequence of node hashes; length = hash_count × hash_size |
| Payload | 1-184 bytes | Yes | Packet data; structure depends on payload type |
Transport Codes
Transport codes are present if and only if the route type (header bits 0-1) is
TRANSPORT_FLOOD (0x00) or TRANSPORT_DIRECT (0x03).
When present, transport codes consist of two little-endian 16-bit unsigned integers, for a total of 4 bytes:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Code 1 (uint16_le) | Code 2 (uint16_le) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
- Code 1: Calculated from regional scope parameters
- Code 2: Reserved (typically 0)
When transport codes are not present (route types FLOOD and DIRECT), the
implementation MUST set both transport code values to zero internally.
Encoding Algorithm
To encode a packet to wire format:
- Write the header byte.
- If the route type is
TRANSPORT_FLOODorTRANSPORT_DIRECT: a. Write transport_codes[0] as uint16_le (2 bytes). b. Write transport_codes[1] as uint16_le (2 bytes). - Write the path_len byte.
- Calculate path byte length as
hash_count × hash_size(see Section 3). - Write
path_byte_lengthbytes from the path array. - Write
payload_lenbytes from the payload array.
The total wire length is:
1 + (hasTransportCodes ? 4 : 0) + 1 + path_byte_length + payload_len
This MUST NOT exceed MAX_TRANS_UNIT (255) bytes.
Decoding Algorithm
To decode a packet from wire format:
- Read 1 byte as the header. Extract route type from bits 0-1.
- If route type is
TRANSPORT_FLOOD(0x00) orTRANSPORT_DIRECT(0x03): a. Read 2 bytes as transport_codes[0] (uint16_le). b. Read 2 bytes as transport_codes[1] (uint16_le). Otherwise, set both transport codes to zero. - Read 1 byte as path_len.
- Validate path_len (see Section 3): a. Extract hash_size = (path_len >> 6) + 1. If hash_size is 4, the packet is INVALID. b. Extract hash_count = path_len & 63. c. If hash_count × hash_size > MAX_PATH_SIZE (64), the packet is INVALID.
- Calculate path_byte_length = hash_count × hash_size.
- Read path_byte_length bytes as the path.
- Let
ibe the current read position. Ifi >= total_length, the packet is INVALID (payload MUST contain at least 1 byte). - Calculate payload_len = total_length - i.
- If payload_len > MAX_PACKET_PAYLOAD (184), the packet is INVALID.
- Read payload_len bytes as the payload.
Size Constraints
| Constraint | Value | Enforcement |
|---|---|---|
| Maximum wire length | 255 bytes | Encoder MUST NOT produce packets exceeding this |
| Maximum payload | 184 bytes | Decoder MUST reject payloads exceeding this |
| Maximum path | 64 bytes | Decoder MUST reject paths exceeding this |
| Minimum payload | 1 byte | Decoder MUST reject packets with zero-length payload |
| Minimum packet | 3 bytes | Header (1) + path_len (1) + payload (1 minimum) |
Error Conditions
A conforming decoder MUST reject packets with any of the following:
- Total wire length less than 3 bytes (no room for header + path_len + payload)
- Total wire length less than minimum required for the route type:
- 3 bytes for FLOOD or DIRECT
- 7 bytes for TRANSPORT_FLOOD or TRANSPORT_DIRECT (adds 4 transport code bytes)
- Invalid path_len encoding (see Section 3)
- Path byte length exceeds remaining bytes
- Zero-length payload after header, transport codes, path_len, and path
- Payload length exceeding MAX_PACKET_PAYLOAD (184)
Cross-References
- Section 2: Header — Header byte encoding details
- Section 3: Path — Path length encoding and path field
- Test vectors:
corpus/wire-format/framing/,corpus/wire-format/invalid/
Reference Implementation
Packet::writeTo()insrc/Packet.cpp— EncodingPacket::readFrom()insrc/Packet.cpp— DecodingPacket::getRawLength()insrc/Packet.cpp— Wire length calculation
MeshCore Protocol Specification
Section 2: Header
Overview
The header is the first byte of every MeshCore packet. It encodes three fields using bit packing: the route type (2 bits), the payload type (4 bits), and the protocol version (2 bits).
Bit Layout
Bit: 7 6 5 4 3 2 1 0
+---+---+---+---+---+---+---+---+
| V1 V0| P3 P2 P1 P0| R1 R0|
+---+---+---+---+---+---+---+---+
| | |
| | +-- Route Type (bits 0-1)
| +-------------- Payload Type (bits 2-5)
+------------------------ Protocol Version (bits 6-7)
The header byte value is computed as:
header = (version << 6) | (payload_type << 2) | route_type
And fields are extracted as:
route_type = header & 0x03
payload_type = (header >> 2) & 0x0F
version = (header >> 6) & 0x03
Route Type (bits 0-1)
The route type determines how the packet is routed through the mesh and whether transport codes are present.
| Value | Name | Transport Codes | Description |
|---|---|---|---|
| 0x00 | TRANSPORT_FLOOD | Yes (4 bytes) | Flood routing with transport codes for regional scoping |
| 0x01 | FLOOD | No | Standard flood routing; path is built up by intermediate nodes |
| 0x02 | DIRECT | No | Direct routing; path is supplied by the sender |
| 0x03 | TRANSPORT_DIRECT | Yes (4 bytes) | Direct routing with transport codes |
Transport codes MUST be present in the wire format when route type is 0x00 or 0x03, and MUST NOT be present when route type is 0x01 or 0x02.
Payload Type (bits 2-5)
The payload type identifies the structure of the payload data.
| Value | Name | Description |
|---|---|---|
| 0x00 | REQUEST | Encrypted request (dest_hash + src_hash + MAC + ciphertext) |
| 0x01 | RESPONSE | Encrypted response to REQ or ANON_REQ |
| 0x02 | TXT_MSG | Encrypted text message |
| 0x03 | ACK | Simple acknowledgment (4-byte CRC) |
| 0x04 | ADVERT | Node identity advertisement |
| 0x05 | GRP_TXT | Encrypted group text message |
| 0x06 | GRP_DATA | Encrypted group datagram |
| 0x07 | ANON_REQ | Anonymous request (full public key instead of hash) |
| 0x08 | PATH | Encrypted returned path information |
| 0x09 | TRACE | Path trace with per-hop SNR collection |
| 0x0A | MULTIPART | One part of a multi-packet sequence |
| 0x0B | CONTROL | Control/discovery packet |
| 0x0C | (reserved) | Reserved for future use |
| 0x0D | (reserved) | Reserved for future use |
| 0x0E | (reserved) | Reserved for future use |
| 0x0F | RAW_CUSTOM | Custom raw bytes for application-defined payloads |
Implementations SHOULD silently discard packets with reserved payload type values (0x0C-0x0E) unless they have explicit support for extended types.
Protocol Version (bits 6-7)
| Value | Name | Description |
|---|---|---|
| 0x00 | V1 | Current version: 1-byte src/dest hashes, 2-byte MAC |
| 0x01 | V2 | Reserved for future use |
| 0x02 | V3 | Reserved for future use |
| 0x03 | V4 | Reserved for future use |
Implementations MUST support version 0x00 (V1). Implementations SHOULD reject packets with unrecognized version values.
Special Header Values
The header value 0xFF is used internally as a “do not retransmit” marker. This
is an in-memory sentinel only and MUST NOT appear on the wire. A header of
0xFF would decode as version=3, payload_type=0x0F (RAW_CUSTOM), route_type=3
(TRANSPORT_DIRECT).
Encoding Examples
| Header Byte | Binary | Version | Payload Type | Route Type |
|---|---|---|---|---|
0x01 | 00 000000 01 | V1 (0) | REQUEST (0) | FLOOD (1) |
0x05 | 00 000001 01 | V1 (0) | RESPONSE (1) | FLOOD (1) |
0x09 | 00 000010 01 | V1 (0) | TXT_MSG (2) | FLOOD (1) |
0x0D | 00 000011 01 | V1 (0) | ACK (3) | FLOOD (1) |
0x11 | 00 000100 01 | V1 (0) | ADVERT (4) | FLOOD (1) |
0x0C | 00 000011 00 | V1 (0) | ACK (3) | TRANSPORT_FLOOD (0) |
0x0E | 00 000011 10 | V1 (0) | ACK (3) | DIRECT (2) |
0x0F | 00 000011 11 | V1 (0) | ACK (3) | TRANSPORT_DIRECT (3) |
0x4D | 01 000011 01 | V2 (1, reserved) | ACK (3) | FLOOD (1) |
Cross-References
- Section 1: Wire Format — Overall packet structure
- Section 3: Path — Path encoding
- Test vectors:
corpus/wire-format/header/
Reference Implementation
Packet::getRouteType()insrc/Packet.h—header & 0x03Packet::getPayloadType()insrc/Packet.h—(header >> 2) & 0x0FPacket::getPayloadVer()insrc/Packet.h—(header >> 6) & 0x03Packet::hasTransportCodes()insrc/Packet.h— route type 0x00 or 0x03- Route type constants in
src/Packet.h - Payload type constants in
src/Packet.h
MeshCore Protocol Specification
Section 3: Path
Overview
The path field carries routing information as a sequence of node hashes. In flood routing, intermediate nodes append their hash to the path as they forward the packet. In direct routing, the sender supplies the full path. The path length byte uses bit packing to encode both the number of hashes and the size of each hash.
Path Length Byte
The path length byte immediately follows the header (and transport codes, if present). It encodes two values:
Bit: 7 6 5 4 3 2 1 0
+---+---+---+---+---+---+---+---+
| S1 S0| C5 C4 C3 C2 C1 C0|
+---+---+---+---+---+---+---+---+
| |
| +-- Hash Count (bits 0-5): 0-63
+----------------- Hash Size Code (bits 6-7): 0-2 (3 is reserved)
| Field | Bits | Extraction | Range | Description |
|---|---|---|---|---|
| Hash Count | 0-5 | path_len & 0x3F | 0-63 | Number of hashes in the path |
| Hash Size Code | 6-7 | path_len >> 6 | 0-2 | Encoded hash size: actual size = code + 1 |
The actual hash size in bytes is:
hash_size = (path_len >> 6) + 1
| Hash Size Code | Actual Hash Size | Description |
|---|---|---|
0 (0b00) | 1 byte | V1 default (PATH_HASH_SIZE) |
1 (0b01) | 2 bytes | Extended precision |
2 (0b10) | 3 bytes | Extended precision |
3 (0b11) | RESERVED | MUST be rejected as invalid |
The path length byte is constructed as:
path_len = ((hash_size - 1) << 6) | (hash_count & 0x3F)
Path Field
The path field immediately follows the path length byte. Its size in bytes is:
path_byte_length = hash_count × hash_size
The path contains hash_count consecutive hashes, each hash_size bytes long.
Each hash is a prefix of a node’s Ed25519 public key (see Section 15).
+----------+----------+-----+----------+
| Hash 0 | Hash 1 | ... | Hash N-1 |
| (H bytes)| (H bytes)| ... | (H bytes)|
+----------+----------+-----+----------+
where H = hash_size, N = hash_count
When hash_count is 0, the path field is empty (zero bytes on the wire).
Validation Rules
A conforming implementation MUST reject a packet if any of the following are true:
-
Reserved hash size: Hash size code is 3 (bits 6-7 =
0b11), meaning hash_size would be 4. This value is reserved for future use. -
Path overflow:
hash_count × hash_size > MAX_PATH_SIZE(64 bytes).
The maximum number of hashes depends on hash size:
| Hash Size | Max Hash Count | Max Path Bytes |
|---|---|---|
| 1 byte | 63 | 63 |
| 2 bytes | 32 | 64 |
| 3 bytes | 21 | 63 |
Note: With 1-byte hashes the maximum hash count is 63 (not 64), because the count field is only 6 bits wide. With 2-byte hashes, 32 × 2 = 64 bytes exactly fills MAX_PATH_SIZE. With 3-byte hashes, 21 × 3 = 63 bytes is the maximum before 22 × 3 = 66 would overflow.
Path Manipulation
Appending a hash (flood routing): When a node forwards a flood-routed packet, it appends its own hash to the path:
- Let
n= current hash_count. - Verify
(n + 1) × hash_size <= MAX_PATH_SIZE. If not, the node MUST NOT forward the packet. - Copy the node’s hash (first
hash_sizebytes of its public key) topath[n × hash_size]. - Increment hash_count:
setPathHashCount(n + 1).
Setting hash size and count:
path_len = ((hash_size - 1) << 6) | (hash_count & 0x3F)
Encoding Examples
| path_len byte | Binary | Hash Size Code | Hash Count | Hash Size | Path Bytes |
|---|---|---|---|---|---|
0x00 | 00 000000 | 0 | 0 | 1 | 0 |
0x01 | 00 000001 | 0 | 1 | 1 | 1 |
0x03 | 00 000011 | 0 | 3 | 1 | 3 |
0x3F | 00 111111 | 0 | 63 | 1 | 63 |
0x41 | 01 000001 | 1 | 1 | 2 | 2 |
0x42 | 01 000010 | 1 | 2 | 2 | 4 |
0x60 | 01 100000 | 1 | 32 | 2 | 64 |
0x81 | 10 000001 | 2 | 1 | 3 | 3 |
0x95 | 10 010101 | 2 | 21 | 3 | 63 |
0xC0 | 11 000000 | 3 | 0 | INVALID | — |
0xFF | 11 111111 | 3 | 63 | INVALID | — |
Known Implementation Discrepancy
The Rust implementation meshcore-rs
treats the path_len byte as a raw byte count rather than decoding hash_count and
hash_size from the bitfields. For example, a path_len value of 0x42 (binary
01 000010) should decode as hash_size=2, hash_count=2, path_bytes=4. The
incorrect interpretation reads it as a raw count of 66 bytes.
Test vectors in this corpus specifically exercise multi-byte hash sizes to catch this class of bug.
Cross-References
- Section 1: Wire Format — Overall packet structure
- Section 2: Header — Header encoding
- Section 15: Identity — Hash derivation from public keys
- Test vectors:
corpus/wire-format/path/
Reference Implementation
Packet::getPathHashSize()insrc/Packet.h—(path_len >> 6) + 1Packet::getPathHashCount()insrc/Packet.h—path_len & 63Packet::getPathByteLen()insrc/Packet.h—getPathHashCount() * getPathHashSize()Packet::setPathHashSizeAndCount()insrc/Packet.h—((sz - 1) << 6) | (n & 63)Packet::isValidPathLen()insrc/Packet.cpp— Validation logicPacket::writePath()insrc/Packet.cpp— Path serialization
MeshCore Protocol Specification
Section 4: Payload — ACK
Overview
The ACK (acknowledgment) payload is the simplest payload type. It consists of a single 4-byte CRC value used to confirm receipt of a previously sent message. The CRC is a truncated SHA-256 over the original TXT_MSG plaintext prefix concatenated with the sender’s public key.
Payload Type
Header payload type field: 0x03 (ACK)
Wire Format
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ACK CRC (uint32_le) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Fields
| Field | Offset | Size | Type | Description |
|---|---|---|---|---|
| ack_crc | 0 | 4 bytes | uint32_le | CRC identifying the acknowledged message |
ACK CRC Computation
The 4-byte ACK CRC is computed as the first 4 bytes of a SHA-256 digest over the acknowledged TXT_MSG’s plaintext prefix followed by the sender’s Ed25519 public key:
ack_crc = SHA-256( plaintext_prefix || sender_pub_key )[0..3]
Where plaintext_prefix is the TXT_MSG plaintext up to and including the
message text, excluding any trailing NUL terminator:
plaintext_prefix = timestamp_le(4) || txt_type_attempt(1) || text
timestamp_leandtxt_type_attemptare defined in Section 6.textis the raw UTF-8 message bytes with no NUL terminator.sender_pub_keyis the full 32-byte Ed25519 public key of the node that originated the TXT_MSG (i.e., the node expecting this ACK back).
For TXT_TYPE_SIGNED_PLAIN messages, the 4-byte signature prefix is included:
plaintext_prefix = timestamp_le(4) || txt_type_attempt(1) || signature(4) || text
ack_crc = SHA-256( plaintext_prefix || receiver_pub_key )[0..3]
Note the SIGNED_PLAIN variant hashes against the receiver’s public key;
see BaseChatMesh::onPeerDataRecv().
Because txt_type_attempt includes the 2-bit attempt counter, each retry
with an incremented attempt produces a different ACK CRC. This lets the
sender attribute each received ACK to a specific transmission attempt. See
Section 6: Reliable DM Delivery.
Encoding
- Compute the 4-byte CRC value for the message being acknowledged (above).
- Write the CRC as a little-endian 32-bit unsigned integer.
- Set payload_len to 4.
Decoding
- Read 4 bytes from the payload as a little-endian uint32.
- If fewer than 4 bytes are available, the packet is INVALID.
Constraints
- The payload MUST be exactly 4 bytes.
- Implementations SHOULD log or discard ACK packets with payload_len < 4.
Cross-References
- Section 1: Wire Format — Packet framing
- Section 11: Multipart — Multipart ACK encoding
- Test vectors:
corpus/payloads/ack/
Reference Implementation
Mesh::createAck()insrc/Mesh.cpp— Packet constructionBaseChatMesh::composeMsgPacket()insrc/helpers/BaseChatMesh.cpp— Sender-side expected-ACK computation (expected_ack)BaseChatMesh::onPeerDataRecv()insrc/helpers/BaseChatMesh.cpp— Receiver-side ACK CRC computation (ack_hash)- ACK reception in
Mesh::onRecvPacket(), casePAYLOAD_TYPE_ACK
MeshCore Protocol Specification
Section 5: Payload — Advertisement
Overview
The advertisement payload allows a node to announce its identity to the mesh. It contains the node’s Ed25519 public key, a timestamp, a signature proving ownership of the key, and optional application data describing the node’s type, location, features, and name.
Payload Type
Header payload type field: 0x04 (ADVERT)
Wire Format
0 31
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Ed25519 Public Key (32 bytes) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Timestamp (uint32_le) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Ed25519 Signature (64 bytes) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| App Data (0-32 bytes) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Fields
| Field | Offset | Size | Type | Description |
|---|---|---|---|---|
| pub_key | 0 | 32 bytes | raw | Ed25519 public key of the advertising node |
| timestamp | 32 | 4 bytes | uint32_le | Unix epoch timestamp of advertisement creation |
| signature | 36 | 64 bytes | raw | Ed25519 signature (see Signature section below) |
| app_data | 100 | 0-32 bytes | structured | Optional application data (see App Data section below) |
Minimum and Maximum Sizes
- Minimum payload: 100 bytes (pub_key + timestamp + signature, no app_data)
- Maximum payload: 132 bytes (100 + MAX_ADVERT_DATA_SIZE of 32)
Signature
The signature is computed over the concatenation of:
message = pub_key(32) || timestamp_le(4) || app_data(0-32)
The node signs this message using its Ed25519 private key. Receivers MUST verify the signature using the public key from the payload. Packets with invalid signatures MUST be discarded.
App Data Format
When present (payload_len > 100), app_data is a structured field beginning with a flags byte:
Bit: 7 6 5 4 3 2 1 0
+-------+-------+-------+-------+---+---+---+---+
|has_ |has_ |has_ |has_ | Node Type |
|name |feat2 |feat1 |loc | (4 bits) |
+-------+-------+-------+-------+---+---+---+---+
| Field | Offset | Size | Condition | Type | Description |
|---|---|---|---|---|---|
| flags | 0 | 1 byte | Always | uint8 | See bit layout above |
| latitude | 1 | 4 bytes | flags bit 4 set | int32_le | Latitude × 1,000,000 |
| longitude | 5 | 4 bytes | flags bit 4 set | int32_le | Longitude × 1,000,000 |
| feat1 | varies | 2 bytes | flags bit 5 set | uint16_le | Feature field 1 |
| feat2 | varies | 2 bytes | flags bit 6 set | uint16_le | Feature field 2 |
| name | varies | remaining | flags bit 7 set | UTF-8 | Node name (no null terminator) |
Fields appear in the order listed. The offset of each field depends on which preceding optional fields are present.
Node Types (flags bits 0-3)
| Value | Name | Description |
|---|---|---|
| 0 | None | Unspecified |
| 1 | Chat | Chat client node |
| 2 | Repeater | Mesh repeater node |
| 3 | Room | Room server node |
| 4 | Sensor | Sensor node |
Decoding Algorithm
- Read 32 bytes as pub_key.
- Read 4 bytes as timestamp (uint32_le).
- Read 64 bytes as signature.
- If offset (100) > payload_len, the packet is INVALID (incomplete).
- If offset (100) == payload_len, there is no app_data. Done.
- Read remaining bytes as app_data (up to MAX_ADVERT_DATA_SIZE = 32 bytes). If more than 32 bytes remain, implementations MUST truncate to 32.
- Construct the signature verification message: pub_key || timestamp_le || app_data.
- Verify the Ed25519 signature. If invalid, the receiver MUST discard the packet.
- Parse app_data: a. Read 1 byte as flags. b. If flags bit 4 set: read 4 bytes latitude (int32_le), 4 bytes longitude (int32_le). c. If flags bit 5 set: read 2 bytes feat1 (uint16_le). d. If flags bit 6 set: read 2 bytes feat2 (uint16_le). e. If flags bit 7 set: read remaining bytes as UTF-8 name.
Cross-References
- Section 15: Identity — Ed25519 key management
- Section 14: Cryptography — Signature operations
- Test vectors:
corpus/payloads/advert/
Reference Implementation
Mesh::createAdvert()insrc/Mesh.cpp— EncodingMesh::onRecvPacket(), casePAYLOAD_TYPE_ADVERTinsrc/Mesh.cpp— Decoding and verification
MeshCore Protocol Specification
Section 6: Payload — Encrypted (REQ / RESPONSE / TXT_MSG)
Overview
The REQUEST, RESPONSE, and TXT_MSG payload types share an identical wire format. They consist of a destination hash, source hash, and an encrypt-then-MAC ciphertext blob. The three types differ only in their semantic meaning at the application layer.
Payload Types
| Value | Name | Description |
|---|---|---|
| 0x00 | REQUEST | Encrypted request (e.g., login, data query) |
| 0x01 | RESPONSE | Encrypted response to a REQUEST or ANON_REQ |
| 0x02 | TXT_MSG | Encrypted text message |
Wire Format
0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| dest_hash(1) | src_hash(1) | Cipher MAC (2 bytes) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Ciphertext (N × 16 bytes, N ≥ 1) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Fields
| Field | Offset | Size | Type | Description |
|---|---|---|---|---|
| dest_hash | 0 | PATH_HASH_SIZE (1) | raw | First byte of recipient’s Ed25519 public key |
| src_hash | 1 | PATH_HASH_SIZE (1) | raw | First byte of sender’s Ed25519 public key |
| cipher_mac | 2 | CIPHER_MAC_SIZE (2) | raw | Truncated HMAC-SHA256 over ciphertext |
| ciphertext | 4 | variable (multiple of 16) | raw | AES-128-ECB encrypted data |
Minimum Size
- Minimum payload: 4 + 16 = 20 bytes (dest_hash + src_hash + MAC + 1 AES block)
Encryption
The ciphertext is produced using the encrypt-then-MAC scheme described in Section 14:
- Compute the ECDH shared secret between sender and recipient.
- Zero-pad the plaintext on the right with
0x00bytes until its length is a multiple of 16 (AES block size). If the plaintext is already a multiple of 16, no padding is added (the scheme is not unambiguous PKCS#7-style padding — the receiver MUST disambiguate trailing zeros using the inner plaintext format; see “Plaintext Format” below). - Encrypt the padded plaintext using AES-128-ECB with the first 16 bytes of the shared secret as the key.
- Compute HMAC-SHA256 over the ciphertext using the full 32-byte shared secret as the HMAC key. Truncate to 2 bytes.
- Prepend the 2-byte MAC to the ciphertext.
Decryption
- Extract dest_hash and src_hash.
- If dest_hash matches this node, search for peers matching src_hash.
- For each matching peer, compute or retrieve the shared secret.
- Verify the MAC: compute HMAC-SHA256 over the ciphertext portion using the full 32-byte shared secret. Compare the first 2 bytes with cipher_mac.
- If the MAC is valid, decrypt the ciphertext using AES-128-ECB with the first 16 bytes of the shared secret. The decrypted data may contain trailing zero bytes from padding.
- If no peer’s MAC matches, the packet is not for this node (or the peer is unknown). The packet MAY still be forwarded.
Plaintext Format (TXT_MSG)
For TXT_MSG payloads, the decrypted plaintext has this structure:
| Field | Offset | Size | Type | Description |
|---|---|---|---|---|
| timestamp | 0 | 4 bytes | uint32_le | Message timestamp |
| txt_type_attempt | 4 | 1 byte | uint8 | Bits 2-7: message type (see Section 4); bits 0-1: attempt counter (0-3). See “Attempt Counter Semantics” below. |
| text | 5 | remaining | UTF-8 | Message text |
Attempt Counter Semantics
The attempt sub-field (bits 0-1 of txt_type_attempt) carries the low two
bits of a transmission attempt counter used by the sender’s retry logic. For
a given logical TXT_MSG:
- The
timestampfield MUST remain stable across all retries of the same message. - The
attemptcounter MUST be 0 on the first transmission and MUST be incremented by 1 on each retry. - Bits 0–1 of
txt_type_attemptMUST always carryattempt & 3. These bits are part of the ACK CRC input (see Section 4), so each retry of a message produces a distinct expected ACK CRC. - For attempts in
0..3, no further encoding is needed; the plaintext ends with the text. - For attempts
> 3, the sender MUST append a 2-byte tail[NUL_terminator(1)][attempt_full(1)]to the plaintext after the text, whereattempt_fullis the full 1-byte counter value (not just the low 2 bits). The receiver locates the text by scanning for the NUL terminator and recovers the full attempt value from the trailing byte. Because this tail costs 2 bytes of plaintext capacity, the maximum text length is reduced by 2 bytes whenattempt > 3. - The ACK CRC is computed over
timestamp(4) || txt_type_attempt(1) || textonly. The[NUL][attempt_full]tail (when present) and the implicit C string NUL terminator (when not) are excluded from the hash input. This means a retry with attempt 4 and a retry with attempt 0 produce ACK CRCs that match (since4 & 3 == 0); senders relying on attempts beyond 3 SHOULD also verify the trailing attempt byte if they need to distinguish rolled-over attempts.
See BaseChatMesh::composeMsgPacket() in
src/helpers/BaseChatMesh.cpp for the reference encoding.
Reliable DM Delivery
The following sender-side behavior is informational and documents how interoperating implementations deliver TXT_MSG reliably. Exact timeouts, backoff schedules, and queue depths are implementation-defined; see Section 17 — Sender Behavior for routing-layer details.
- Expected-ACK tracking. On transmit, the sender SHOULD compute the
expected
ack_crc(see Section 4) and record it in a pending-ACK table keyed by CRC. The table SHOULD bound entries by capacity and age out entries after the retry schedule has completed (plus a margin for in-flight ACKs). - Retry. If no matching ACK is received within an implementation-defined
timeout, the sender SHOULD retransmit with
attemptincremented (per “Attempt Counter Semantics” above) and re-register the new expectedack_crc. Up to 4 attempts are representable in the 2-bitattemptfield. - ACK consumption. On receipt of a PAYLOAD_TYPE_ACK or a MULTIPART-wrapped
ACK whose
ack_crcmatches a pending entry, the sender MUST treat the message as delivered, remove the pending-ACK entry, and cancel any queued retries for that CRC. - Duplicate DM handling at the receiver. A receiver MAY cache its most recent reply per peer keyed by the inbound DM’s plaintext and, on a duplicate inbound DM within a short window, re-emit the cached reply as a loss-recovery optimization. This is not normative; the normative behavior is “process once and rely on Section 16 dedup.” Implementations that re-emit cached replies SHOULD suppress re-emission while a retry for that reply is still pending, to avoid piling on.
Plaintext Format (REQUEST / RESPONSE)
The plaintext format for REQUEST and RESPONSE is application-defined. Common patterns include:
- REQUEST:
[timestamp(4)][request_data...] - RESPONSE:
[timestamp(4)][response_data...]
Cross-References
- Section 14: Cryptography — Encrypt-then-MAC details
- Section 15: Identity — ECDH shared secret computation
- Test vectors:
corpus/payloads/encrypted/
Reference Implementation
Mesh::createDatagram()insrc/Mesh.cpp— EncodingMesh::onRecvPacket(), casesPAYLOAD_TYPE_REQ/RESPONSE/TXT_MSG— DecodingUtils::encryptThenMAC()insrc/Utils.cpp— EncryptionUtils::MACThenDecrypt()insrc/Utils.cpp— Decryption and verification
MeshCore Protocol Specification
Section 7: Payload — Anonymous Request
Overview
The anonymous request payload allows a node to send an encrypted message to a recipient without the recipient needing to know the sender in advance. Instead of a 1-byte source hash, the full 32-byte Ed25519 public key of the sender is included, enabling the recipient to compute the shared secret for decryption.
Payload Type
Header payload type field: 0x07 (ANON_REQ)
Wire Format
0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| dest_hash(1) | |
+-+-+-+-+-+-+-+-+ |
| Sender Public Key (32 bytes) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Cipher MAC (2 bytes) | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
| Ciphertext (N × 16 bytes, N ≥ 1) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Fields
| Field | Offset | Size | Type | Description |
|---|---|---|---|---|
| dest_hash | 0 | 1 byte | raw | First byte of recipient’s Ed25519 public key |
| sender_pub_key | 1 | 32 bytes | raw | Full Ed25519 public key of sender |
| cipher_mac | 33 | 2 bytes | raw | Truncated HMAC-SHA256 over ciphertext |
| ciphertext | 35 | variable (multiple of 16) | raw | AES-128-ECB encrypted data |
Minimum Size
- Minimum payload: 1 + 32 + 2 + 16 = 51 bytes
Encryption
- Sender computes ECDH shared secret using its private key and the recipient’s public key.
- Encrypt plaintext using AES-128-ECB with first 16 bytes of shared secret.
- Compute HMAC-SHA256 over ciphertext with full 32-byte shared secret. Truncate to 2 bytes.
- Assemble: dest_hash || sender_pub_key || MAC || ciphertext.
Decryption
- Read dest_hash. If it does not match this node, the packet is not for us (but MAY be forwarded).
- Read 32-byte sender_pub_key. Construct an Identity from it.
- Compute ECDH shared secret between this node’s private key and sender’s public key.
- Verify the MAC and decrypt as in Section 14.
Cross-References
- Section 6: Encrypted Payloads — Standard encrypted format
- Section 14: Cryptography — Encrypt-then-MAC
- Section 15: Identity — ECDH key exchange
- Test vectors:
corpus/payloads/anon-req/
Reference Implementation
Mesh::createAnonDatagram()insrc/Mesh.cpp— EncodingMesh::onRecvPacket(), casePAYLOAD_TYPE_ANON_REQ— Decoding
MeshCore Protocol Specification
Section 8: Payload — Group (GRP_TXT / GRP_DATA)
Overview
Group payloads enable encrypted communication within a channel. All members of a channel share a symmetric secret. The payload begins with a 1-byte channel hash (derived from the channel’s shared key), followed by an encrypt-then-MAC blob.
Payload Types
| Value | Name | Description |
|---|---|---|
| 0x05 | GRP_TXT | Group text message |
| 0x06 | GRP_DATA | Group datagram (binary data) |
Wire Format
0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|channel_hash(1)| Cipher MAC (2 bytes) | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |
| Ciphertext (N × 16 bytes, N ≥ 1) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Fields
| Field | Offset | Size | Type | Description |
|---|---|---|---|---|
| channel_hash | 0 | 1 byte | raw | First byte of SHA-256(channel_shared_key) |
| cipher_mac | 1 | 2 bytes | raw | Truncated HMAC-SHA256 over ciphertext |
| ciphertext | 3 | variable (multiple of 16) | raw | AES-128-ECB encrypted data |
Minimum Size
- Minimum payload: 1 + 2 + 16 = 19 bytes
Channel Hash Derivation
The channel_hash is the first byte of the SHA-256 hash of the channel’s 32-byte shared key (PUB_KEY_SIZE bytes):
channel_hash = SHA256(channel_shared_key)[0]
This means hash collisions are expected (1 in 256 chance). When a receiver finds a matching channel_hash, it attempts decryption with each matching channel’s secret. Only a successful MAC verification confirms the correct channel.
Encryption
- Encrypt plaintext using AES-128-ECB with first 16 bytes of the channel’s shared key (GroupChannel.secret).
- Compute HMAC-SHA256 over ciphertext using all 32 bytes of the channel’s shared key. Truncate to 2 bytes.
- Assemble: channel_hash || MAC || ciphertext.
Decryption
- Read channel_hash (1 byte).
- Search local channel database for channels with matching hash. Multiple channels may match (up to 4 in reference implementation).
- For each matching channel, attempt MAC verification and decryption using the channel’s shared key.
- The first channel that produces a valid MAC yields the correct decryption.
Cross-References
- Section 14: Cryptography — Encrypt-then-MAC
- Test vectors:
corpus/payloads/group/
Reference Implementation
Mesh::createGroupDatagram()insrc/Mesh.cpp— EncodingMesh::onRecvPacket(), casesPAYLOAD_TYPE_GRP_TXT/GRP_DATA— DecodingMesh::searchChannelsByHash()— Channel lookup
MeshCore Protocol Specification
Section 9: Payload — Path Return
Overview
The PATH payload carries an encrypted return path from one node to another. It uses the same dest_hash + src_hash + encrypt-then-MAC envelope as REQ/RESPONSE/ TXT_MSG, but the decrypted contents contain routing path data and optional extra application data.
Payload Type
Header payload type field: 0x08 (PATH)
Wire Format (Outer)
0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| dest_hash(1) | src_hash(1) | Cipher MAC (2 bytes) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| |
| Ciphertext (N × 16 bytes, N ≥ 1) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
The outer format is identical to Section 6.
Decrypted Inner Format
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| path_len(1) | Path (hash_count × hash_size) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| extra_type(1) | Extra Data (variable) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Field | Size | Type | Description |
|---|---|---|---|
| path_len | 1 byte | encoded | Same bitfield encoding as packet path_len (see Section 3) |
| path | hash_count × hash_size | raw | The return path to the sender |
| extra_type | 1 byte | uint8 | Type of extra data (lower 4 bits used; upper 4 reserved) |
| extra | remaining | raw | Extra data (may include trailing zero padding from AES) |
Extra Type Values
| Value | Description |
|---|---|
| 0x00 | No meaningful extra data (reciprocal path) |
| 0xFF | Dummy/padding (used when no real extra data; followed by 4 random bytes) |
| Other | Application-defined |
When creating a path return with no extra data, the reference implementation
appends 0xFF as extra_type followed by 4 random bytes to ensure the packet
hash is unique.
Encoding
- Build the inner plaintext:
a. Write path_len byte (encoded hash_size and hash_count).
b. Write path bytes (hash_count × hash_size).
c. If extra data is provided: write extra_type (lower 4 bits), write extra.
d. If no extra data: write
0xFF, write 4 random bytes. - Encrypt the inner plaintext using encrypt-then-MAC with the shared secret.
- Prepend dest_hash and src_hash.
Decoding
- Decode the outer envelope (dest_hash, src_hash, MAC, ciphertext) as in Section 6.
- After decryption: a. Read 1 byte as path_len. Decode hash_size and hash_count. b. Read hash_count × hash_size bytes as the path. c. Read 1 byte as extra_type (use lower 4 bits only). d. Remaining bytes are extra data (may include AES zero-padding).
Cross-References
- Section 3: Path — Path length encoding
- Section 6: Encrypted Payloads — Outer envelope format
- Section 14: Cryptography — Encrypt-then-MAC
- Test vectors:
corpus/payloads/path-return/
Reference Implementation
Mesh::createPathReturn()insrc/Mesh.cpp— EncodingMesh::onRecvPacket(), PATH case within REQ/RESPONSE/TXT_MSG handling — Decoding
MeshCore Protocol Specification
Section 10: Payload — Trace
Overview
The TRACE payload enables path discovery with per-hop signal quality measurement. A trace packet travels along a specified direct path, and each intermediate node appends its received SNR value to the packet’s path field. When the trace reaches its final hop, the accumulated SNR values and node hashes are delivered.
Payload Type
Header payload type field: 0x09 (TRACE)
Wire Format
The trace payload has a fixed 9-byte header followed by an optional path hashes section (appended when sent via direct routing):
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Tag (uint32_le) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Auth Code (uint32_le) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Flags | Path Hashes (variable) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Fields
| Field | Offset | Size | Type | Description |
|---|---|---|---|---|
| tag | 0 | 4 bytes | uint32_le | Random tag set by the initiator for correlation |
| auth_code | 4 | 4 bytes | uint32_le | Authentication code |
| flags | 8 | 1 byte | uint8 | Flags (see below) |
| path_hashes | 9 | variable | raw | Node hashes for the path to trace (appended for direct send) |
Flags Field
Bit: 7 6 5 4 3 2 1 0
+---+---+---+---+---+---+---+---+
| Reserved | S1 S0|
+---+---+---+---+---+---+---+---+
| Bits | Description |
|---|---|
| 0-1 | Path hash size for the trace path hashes (as a power of 2: 0=1 byte, 1=2 bytes, 2=4 bytes) |
| 2-7 | Reserved (must be zero) |
Note: The flags field path hash size encoding (1 << (flags & 0x03)) differs
from the packet path_len encoding ((code >> 6) + 1).
Direct Sending
When a TRACE packet is sent via direct routing, the path hashes are appended directly to the payload (after the 9-byte header), not placed in the packet’s path field. The packet’s path field is instead used to accumulate SNR values from intermediate nodes.
On creation: payload = [tag(4)][auth_code(4)][flags(1)] (9 bytes)
On sendDirect: payload += path_hashes (9 + N bytes)
packet.path_len = 0 (path used for SNR)
SNR Accumulation
As each intermediate node forwards a TRACE packet, it appends its received SNR value as a single signed byte to the packet’s path field:
path[path_len++] = (int8_t)(SNR × 4)
The SNR value is stored as SNR × 4 for 0.25 dB precision, matching the format used in RxMeta frames.
Trace Completion
A TRACE is considered complete when the path_len counter (used as an offset into
the path hashes in the payload) reaches or exceeds the remaining path hashes.
At that point, onTraceRecv is called with:
- The accumulated SNR values (in packet.path)
- The path hashes (in packet.payload, after byte 9)
- The path length (number of hops traversed)
Minimum Size
- Minimum payload: 9 bytes (tag + auth_code + flags, no path hashes)
Cross-References
- Section 16: Packet Hash — TRACE packets include path_len in hash
- Test vectors:
corpus/payloads/trace/
Reference Implementation
Mesh::createTrace()insrc/Mesh.cpp— Creation (9-byte base)Mesh::sendDirect()— Path appended to payload for TRACE typeMesh::onRecvPacket(), TRACE handling — SNR accumulation and completion check
MeshCore Protocol Specification
Section 11: Payload — Multipart
Overview
The MULTIPART payload wraps another payload type to indicate it is part of a multi-packet sequence. The first byte encodes the number of remaining packets in the sequence and the sub-payload type. Currently, only multipart ACK is defined in the reference implementation.
Payload Type
Header payload type field: 0x0A (MULTIPART)
Wire Format
0 1
0 1 2 3 4 5 6 7 8 9 0 ...
+-+-+-+-+-+-+-+-+-+-+-+-+
|Rem| SubType | Sub-payload |
+-+-+-+-+-+-+-+-+-+-+-+-+
First byte encoding:
Bit: 7 6 5 4 3 2 1 0
+---+---+---+---+---+---+---+---+
| Remaining (4) | Sub-Type (4) |
+---+---+---+---+---+---+---+---+
Fields
| Field | Offset | Size | Type | Description |
|---|---|---|---|---|
| remaining | 0 (bits 4-7) | 4 bits | uint | Number of remaining packets in sequence (0-15) |
| sub_type | 0 (bits 0-3) | 4 bits | uint | Payload type of the wrapped content |
| sub_payload | 1 | variable | raw | The wrapped payload data |
first_byte = (remaining << 4) | (sub_type & 0x0F)
Multipart ACK
The only currently defined multipart sub-type is ACK (sub_type = 0x03):
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| (rem<<4)|0x03| ACK CRC (uint32_le) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Total payload: 5 bytes (1 byte header + 4 byte CRC).
Multipart ACKs are used to send additional ACK retransmissions for reliability.
The remaining field indicates how many more ACK packets follow this one,
counting down: a chain of K extra copies is emitted as MULTIPART with
remaining = K, K-1, …, 1, followed by exactly one final plain
PAYLOAD_TYPE_ACK packet (NOT a MULTIPART with remaining = 0). All copies
in the chain carry the same ack_crc. Receivers MUST treat the first copy
received as the authoritative ACK and dedup subsequent copies by ack_crc
at the application layer.
Multipart ACK Usage Constraints
- A sender MUST only emit MULTIPART-wrapped ACKs on direct-routed return
paths (i.e., when a direct path to the original sender is known). When the
return path is unknown, the sender MUST fall back to emitting a plain
PAYLOAD_TYPE_ACK via flood routing. This reflects the firmware behavior in
Mesh::routeDirectRecvAcks()(which emits MULTIPART copies only when a direct path is available) andBaseChatMesh::sendAckTo()(which falls back tosendFloodScoped()whenout_path_len == OUT_PATH_UNKNOWN). - All MULTIPART-ACK copies and the final plain ACK for the same acknowledged
message carry an identical 4-byte
ack_crc. Receivers MUST treat them as the same logical ACK: the first one received satisfies the acknowledgment, and subsequent duplicates MUST be deduplicated per Section 16 (each copy has a distinct packet hash because the wrapper byte differs, so ordinary dedup does not suppress them on the wire — application-level dedup byack_crcis required). - The typical inter-copy delay in the reference implementation is
approximately 300 ms plus the direct retransmit delay
(
Mesh::routeDirectRecvAcks()); exact timing is implementation-defined. - The number of extra copies is configured via
getExtraAckTransmitCount()and is implementation-defined. The firmware default is 0 (plain ACK only).
A receiver MUST NOT infer delivery reliability from the remaining counter
alone: the counter is a hint about how many additional copies the sender
intends to emit, not a guarantee that they will arrive.
Encoding (Multipart ACK)
- Set first byte to
(remaining << 4) | PAYLOAD_TYPE_ACK. - Copy the 4-byte ACK CRC starting at offset 1.
- Set payload_len to 5.
Decoding (Multipart ACK)
- Read first byte. Extract remaining = byte >> 4, sub_type = byte & 0x0F.
- If sub_type == 0x03 (ACK) and payload_len >= 5: a. Read 4 bytes at offset 1 as ACK CRC (uint32_le).
- For other sub_types: reserved for future use.
Constraints
- Payload MUST be at least 2 bytes (1 header + 1 sub-payload minimum).
- For multipart ACK, payload MUST be at least 5 bytes.
Cross-References
- Section 4: ACK — ACK CRC format
- Test vectors:
corpus/payloads/multipart/
Reference Implementation
Mesh::createMultiAck()insrc/Mesh.cpp— EncodingMesh::routeDirectRecvAcks()insrc/Mesh.cpp— Direct-only gating and inter-copy spacingBaseChatMesh::sendAckTo()insrc/helpers/BaseChatMesh.cpp— Flood fallback when no direct return path is knownMesh::onRecvPacket(), casePAYLOAD_TYPE_MULTIPART— Decoding
MeshCore Protocol Specification
Section 12: Payload — Control
Overview
The CONTROL payload carries control and discovery data. Control packets are raw byte payloads whose structure is determined by the first byte. A subset of control packets (those with bit 7 of the first byte set) are restricted to zero-hop delivery only.
Payload Type
Header payload type field: 0x0B (CONTROL)
Wire Format
0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Control Byte | Data... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Fields
| Field | Offset | Size | Type | Description |
|---|---|---|---|---|
| control_byte | 0 | 1 byte | uint8 | Control type identifier |
| data | 1 | variable | raw | Control-specific data |
Zero-Hop Restriction
When bit 7 (0x80) of the control byte is set, the packet is restricted to
zero-hop delivery only. The reference implementation enforces this:
- If
(payload[0] & 0x80) != 0AND the packet is direct-routed:- It is processed only if
getPathHashCount() == 0. - It is NOT forwarded to other nodes.
- It is processed only if
Discovery Protocols
Control packets are commonly used for node discovery. The specific sub-protocol formats are application-defined, but typically include:
- Discovery Request: Sent zero-hop to find nearby nodes
- Discovery Response: Contains node type, name, SNR, and other metadata
Encoding
- Construct the raw control data bytes.
- If the control packet should be zero-hop only, set bit 7 of the first byte.
- Copy data to payload, set payload_len.
Constraints
- Payload MUST be at least 1 byte (the control byte).
- Maximum payload: 184 bytes (MAX_PACKET_PAYLOAD).
Cross-References
- Section 17: Routing — Zero-hop delivery
- Test vectors:
corpus/payloads/control/
Reference Implementation
Mesh::createControlData()insrc/Mesh.cpp— EncodingMesh::onRecvPacket(), CONTROL handling — Zero-hop check and delivery
MeshCore Protocol Specification
Section 13: Payload — Raw Custom
Overview
The RAW_CUSTOM payload carries application-defined raw bytes with no prescribed structure. It is intended for applications that implement their own encryption, framing, or payload formats on top of the MeshCore transport.
Payload Type
Header payload type field: 0x0F (RAW_CUSTOM)
Wire Format
0
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Raw Data (1-184 bytes) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Fields
| Field | Offset | Size | Type | Description |
|---|---|---|---|---|
| data | 0 | 1-184 bytes | raw | Application-defined payload |
Routing
In the current reference implementation, RAW_CUSTOM packets are only processed when received via direct routing. They are NOT flood-routed.
Constraints
- Payload MUST be at least 1 byte.
- Maximum payload: 184 bytes (MAX_PACKET_PAYLOAD).
- Maximum total data: limited by
sizeof(Packet::payload)which is MAX_PACKET_PAYLOAD (184).
Cross-References
- Test vectors:
corpus/payloads/raw-custom/
Reference Implementation
Mesh::createRawData()insrc/Mesh.cpp— EncodingMesh::onRecvPacket(), casePAYLOAD_TYPE_RAW_CUSTOM— Processing (direct only)
MeshCore Protocol Specification
Section 14: Cryptography
Overview
MeshCore uses a combination of symmetric and asymmetric cryptography. Data confidentiality is provided by AES-128 in ECB mode with zero padding. Data integrity is provided by HMAC-SHA256 truncated to 2 bytes. These are combined in an encrypt-then-MAC scheme.
Algorithms
| Operation | Algorithm | Key Size | Output |
|---|---|---|---|
| Symmetric Encryption | AES-128-ECB | 16 bytes | Multiple of 16 bytes |
| Message Authentication | HMAC-SHA256 | 32 bytes | 2 bytes (truncated) |
| Digital Signature | Ed25519 | 32/64 bytes | 64 bytes |
| Key Exchange | X25519 (ECDH) | 32 bytes | 32 bytes |
| Hashing | SHA-256 | — | Up to 32 bytes |
AES-128-ECB Encryption
MeshCore uses AES-128 in Electronic Codebook (ECB) mode with zero-byte padding.
Key: The first 16 bytes (CIPHER_KEY_SIZE) of the shared secret.
Encryption:
- Set the AES-128 key to
shared_secret[0..15]. - Process the plaintext in 16-byte blocks:
a. For each complete 16-byte block, encrypt in place.
b. For the final partial block (< 16 bytes):
- Copy the remaining bytes to a 16-byte buffer.
- Fill remaining bytes with zeros.
- Encrypt the padded block.
- If the plaintext is empty, encrypt a 16-byte all-zero block.
- The output is always a multiple of 16 bytes.
Decryption:
- Set the AES-128 key to
shared_secret[0..15]. - Process the ciphertext in 16-byte blocks, decrypting each.
- The output length equals the ciphertext length.
- The final block MAY contain trailing zero bytes from padding. The application must know the original plaintext length or use a length-prefixed format to determine where meaningful data ends.
HMAC-SHA256 (Truncated to 2 Bytes)
MeshCore computes HMAC-SHA256 over the ciphertext and truncates the result to CIPHER_MAC_SIZE (2) bytes.
Key: The full 32 bytes (PUB_KEY_SIZE) of the shared secret.
Note: The HMAC key uses the full 32-byte shared secret, while the AES key uses only the first 16 bytes. This is an important distinction.
Computation:
mac = HMAC-SHA256(key=shared_secret[0..31], message=ciphertext)[0..1]
The first 2 bytes of the HMAC-SHA256 output are used as the MAC.
Encrypt-then-MAC
The encrypt-then-MAC scheme combines AES-128-ECB encryption with HMAC-SHA256 authentication:
Encoding (encryptThenMAC):
- Encrypt the plaintext using AES-128-ECB (see above).
Store ciphertext at
dest[2..](offset by MAC size). - Compute HMAC-SHA256 over the ciphertext using the full 32-byte shared secret. Truncate to 2 bytes.
- Store the 2-byte MAC at
dest[0..1]. - Return total length: 2 + ciphertext_length.
Output format:
[MAC (2 bytes)][Ciphertext (N × 16 bytes)]
Decoding (MACThenDecrypt):
- If input length ≤ 2 (CIPHER_MAC_SIZE), return failure (invalid).
- Recompute HMAC-SHA256 over
src[2..](the ciphertext portion) using the full 32-byte shared secret. Truncate to 2 bytes. - Compare the computed MAC with
src[0..1]. - If MACs do not match, return failure (0 = invalid HMAC).
- If MACs match, decrypt
src[2..]using AES-128-ECB. - Return the decrypted plaintext length.
Security Notes
- ECB mode: AES-ECB encrypts each 16-byte block independently. Identical plaintext blocks produce identical ciphertext blocks. This is a known weakness of ECB mode, but MeshCore’s short payloads and mesh-network context make this acceptable for its use case.
- 2-byte MAC: The truncated HMAC provides only 16 bits of authentication. This means there is approximately a 1-in-65536 chance of a forged MAC being accepted. This trade-off prioritizes bandwidth over authentication strength.
- Key derivation: The shared secret is used directly as both AES key (first 16 bytes) and HMAC key (full 32 bytes). No key derivation function (KDF) is applied.
Cross-References
- Section 6: Encrypted Payloads — Usage in packet payloads
- Section 15: Identity — Shared secret computation via ECDH
- Test vectors:
corpus/crypto/
Reference Implementation
Utils::encrypt()insrc/Utils.cpp— AES-128-ECB encryptionUtils::decrypt()insrc/Utils.cpp— AES-128-ECB decryptionUtils::encryptThenMAC()insrc/Utils.cpp— Encrypt-then-MACUtils::MACThenDecrypt()insrc/Utils.cpp— MAC-then-Decrypt
MeshCore Protocol Specification
Section 15: Identity
Overview
Every MeshCore node has an Ed25519 key pair. The 32-byte public key serves as the node’s identity. Hashes used in routing are truncated prefixes of the public key. Shared secrets for encryption are computed via ECDH key exchange using X25519 (Ed25519 keys transposed to Curve25519).
Key Types
| Type | Size | Description |
|---|---|---|
| Public Key | 32 bytes (PUB_KEY_SIZE) | Ed25519 public key |
| Private Key | 64 bytes (PRV_KEY_SIZE) | Ed25519 private key (seed + public) |
| Signature | 64 bytes (SIGNATURE_SIZE) | Ed25519 signature |
| Shared Secret | 32 bytes (PUB_KEY_SIZE) | ECDH shared secret |
Hash Derivation
A node’s routing hash is simply a prefix of its public key:
hash = pub_key[0..hash_size-1]
Where hash_size is 1, 2, or 3 bytes depending on the protocol version and
path encoding (see Section 3).
For V1, PATH_HASH_SIZE is 1, meaning a node’s hash is the first byte of its
public key. This gives only 256 unique hash values, so hash collisions are
common and expected. The protocol handles this by attempting decryption with all
peers matching a given hash.
Hash Matching
To check if a hash matches a node’s identity:
match = memcmp(hash, pub_key, hash_size) == 0
Ed25519 Signing
Used for advertisement signatures (see Section 5):
- Construct the message to sign (e.g., pub_key || timestamp || app_data).
- Sign using the Ed25519 private key.
- The 64-byte signature is included in the advertisement payload.
Verification:
- Extract the public key and signature from the payload.
- Reconstruct the message.
- Verify the signature using Ed25519 verification.
- Receivers MUST discard the packet if verification fails.
ECDH Key Exchange (X25519)
To compute a shared secret between two nodes:
- Convert the Ed25519 keys to X25519 (Curve25519) format.
- Perform X25519 Diffie-Hellman:
shared_secret = X25519(my_private, their_public). - The resulting 32-byte shared secret is used for:
- AES-128 key:
shared_secret[0..15] - HMAC-SHA256 key:
shared_secret[0..31]
- AES-128 key:
Identity Serialization
Identities can be serialized for storage:
- Public Identity: 32 bytes (public key only)
- Local Identity: 64 bytes (private key) + 32 bytes (public key) = 96 bytes
Hex representation uses uppercase characters with 2 hex digits per byte.
Cross-References
- Section 3: Path — Hash size in path encoding
- Section 5: Advertisement — Signature usage
- Section 14: Cryptography — Shared secret usage
- Test vectors:
corpus/crypto/ed25519/,corpus/crypto/ecdh/
Reference Implementation
Identityclass insrc/Identity.h— Public key and verificationLocalIdentityclass insrc/Identity.h— Key pair, signing, ECDHIdentity::copyHashTo()— Hash derivation (prefix copy)Identity::isHashMatch()— Hash comparisonLocalIdentity::calcSharedSecret()— ECDH key exchange
MeshCore Protocol Specification
Section 16: Packet Hash
Overview
MeshCore uses SHA-256 hashing for packet deduplication. Each node maintains a table of recently seen packet hashes and discards packets it has already processed. The hash is computed over the payload type and payload contents, with a special case for TRACE packets.
Hash Computation
The packet hash is computed as:
hash = SHA-256(payload_type_byte || payload_data)[0..MAX_HASH_SIZE-1]
Where:
payload_type_byteis a single byte:(header >> 2) & 0x0Fpayload_datais the raw payload bytes (payload_len bytes)- The SHA-256 output is truncated to MAX_HASH_SIZE (8) bytes
TRACE Packet Special Case
For TRACE packets (payload_type = 0x09), the path_len byte is included in the hash to distinguish trace packets that revisit the same node on their return path:
hash = SHA-256(payload_type_byte || path_len_byte || payload_data)[0..MAX_HASH_SIZE-1]
Algorithm
function calculatePacketHash(packet):
sha = SHA256_init()
type_byte = getPayloadType(packet.header) // single byte
SHA256_update(sha, type_byte)
if type_byte == 0x09: // TRACE
SHA256_update(sha, packet.path_len) // 1 byte (as uint8)
SHA256_update(sha, packet.payload, packet.payload_len)
hash = SHA256_finalize(sha)
return hash[0..7] // first MAX_HASH_SIZE bytes
Properties
- The hash does NOT include the header byte, path, or transport codes. This means the same logical message received via different routes produces the same hash.
- The hash does NOT include the route type or version. Only the payload type and payload content determine the hash.
- For TRACE packets, including path_len ensures that the same trace payload at different stages of traversal produces different hashes.
Cross-References
- Section 10: Trace — TRACE packet special handling
- Test vectors:
corpus/crypto/sha256/packet-hash.json
Reference Implementation
Packet::calculatePacketHash()insrc/Packet.cppMeshTables::hasSeen()— Deduplication table lookup
MeshCore Protocol Specification
Section 17: Routing
Overview
MeshCore supports two routing modes: flood routing and direct routing. Flood routing broadcasts packets to all neighbors, building up a path as the packet propagates. Direct routing sends packets along a pre-determined path.
Flood Routing
In flood routing, the sender emits a packet with an empty path. Each intermediate node that forwards the packet appends its hash to the path before retransmitting.
Sending (flood):
- Set route type to FLOOD (0x01) or TRANSPORT_FLOOD (0x00).
- Set path to empty:
setPathHashSizeAndCount(hash_size, 0). - Transmit the packet.
Forwarding (flood):
- Receive a flood-routed packet.
- Check the deduplication table. If already seen, discard.
- Process the packet (decrypt if addressed to this node, etc.).
- If the packet should be forwarded (node is a repeater):
a. Verify
(hash_count + 1) × hash_size <= MAX_PATH_SIZE. b. Append this node’s hash atpath[hash_count × hash_size]. c. Increment hash_count. d. Retransmit with a random delay.
Path growth: The path grows by one hash per hop. When the path reaches MAX_PATH_SIZE (64 bytes), no more nodes can be appended and the packet stops propagating.
Direct Routing
In direct routing, the sender provides the complete path. Each intermediate node checks whether it is the next hop, removes itself from the path, and forwards to the next node.
Sending (direct):
- Set route type to DIRECT (0x02) or TRANSPORT_DIRECT (0x03).
- Set the path to the destination’s known route.
- Transmit the packet.
Forwarding (direct):
- Receive a direct-routed packet.
- Check if the first hash in the path matches this node.
- If yes, and the node allows forwarding: a. Remove the first hash from the path (shift remaining hashes left). b. Decrement hash_count. c. Retransmit.
- If no, discard (this node is not the next hop).
Path removal (removeSelfFromPath):
- Decrement hash_count.
- Shift the path array: for each index k from 0 to (hash_count × hash_size),
copy
path[k + hash_size]topath[k].
Zero-Hop Delivery
Zero-hop packets are direct-routed packets with hash_count = 0. They reach only immediate neighbors (nodes within radio range). Used for:
- Control packets with bit 7 set in the control byte
- Discovery protocols
- Local-only operations
Transport Codes
Transport codes (present in TRANSPORT_FLOOD and TRANSPORT_DIRECT) enable regional scoping. Nodes can filter packets based on transport codes, accepting only packets from their region.
TRACE Routing
TRACE packets are a special case. They are sent via direct routing, but instead of the normal path field, the path hashes are appended to the payload. The packet’s path field is repurposed to accumulate SNR values from each hop (see Section 10).
Retransmission Priority
Packets are prioritized for transmission:
| Priority | Packet Type | Description |
|---|---|---|
| 0 (highest) | Direct routed | Routed traffic |
| 1 | Path return, standard | Most flood packets |
| 2 | Path return (flood) | Path packets |
| 3 | Advertisement | De-prioritized |
| 5 | Trace | Trace forwarding |
| N (hash_count) | Flood forwarded | Lower priority for more distant sources |
Sender Behavior (Informational)
This section documents sender-side behavior that is not fully constrained by the wire protocol but is common across interoperating implementations. The reference firmware establishes the baseline; client implementations SHOULD follow these patterns to interoperate cleanly. Specific numeric tuning parameters (timeouts, queue sizes, retry counts) are implementation-defined.
Path Learning
- When a node receives a flood-routed packet from a peer, the accumulated
pathfield records the hops the packet traversed. The receiving node SHOULD cache the reversed path keyed by the peer’s source hash and subsequently use that cached path to send direct-routed traffic back. - When multiple paths are observed for the same peer, the reference
implementation unconditionally replaces the cached path with the most
recently observed one (
BaseChatMesh::onContactPathRecv()insrc/helpers/BaseChatMesh.cpp— its source comment explicitly flags hash-size or SNR-based selection as future work). Implementations MAY layer a quality heuristic on top (e.g., prefer larger hash sizes or better SNR margin) if they store and compare multiple candidate paths, but this is not part of the reference behavior and the exact policy is implementation-defined. - Cached paths in the reference implementation do not expire on a timer; they live as long as the contact entry, and are overwritten whenever a fresher path is observed. Implementations MAY add a TTL or age-based eviction policy as an extension. The reference implementation does not persist paths across reboots.
PATH_RETURN
After receiving a flood-routed DM, a receiver MAY proactively emit a
PAYLOAD_TYPE_PATH packet (Section 9) that embeds the
reversed path and optionally an ACK as extra data. This lets the original
sender learn a direct return path before its first reply, converting
subsequent traffic from flood to direct routing. See
BaseChatMesh::onPeerDataRecv() and Mesh::createPathReturn().
The reference implementation schedules the PATH_RETURN transmission with a
small delay (TXT_ACK_DELAY, default 200 ms) to avoid colliding with other
packets the receiver is about to emit. Exact timing is
implementation-defined; the goal is to space PATH_RETURN, ACK, and any
application reply across distinct on-air slots.
Flood Fallback and Retries
- A sender SHOULD track outstanding DMs by their expected
ack_crc(see Section 4) and retry transmissions that are not acknowledged within a timeout. Timeout values and backoff schedules are implementation-defined and typically depend on LoRa airtime parameters. - On each retry, the sender MUST increment the
attemptsub-field oftxt_type_attempt(see Section 6). Thetimestampfield MUST be held constant across retries of the same logical message. This changes the ACK CRC per attempt, letting the sender attribute a returned ACK to a specific transmission. - After N unsuccessful direct-routed retries, the sender SHOULD discard the cached direct path and retry via flood routing, so that a stale cached path does not indefinitely block delivery. The value of N is implementation-defined (the reference is small — single-digit).
- On receipt of a matching ACK (plain or MULTIPART, per Section 11), the sender MUST cancel any queued retries for that ACK CRC.
Transmission Priority (sender-originated)
Client implementations that maintain a TX queue SHOULD prioritize sender-originated packets roughly as follows (highest first):
| Priority | Packet Type |
|---|---|
| 0 | ACK (plain or MULTIPART) |
| 1 | PATH / PATH_RETURN |
| 2 | Direct-routed reply (DM) |
| 3 | Flood-routed reply (DM) |
| 4 | Request/response (e.g., login, keep-alive) |
| 5 | Group text / group data |
| 6 | Advertisement |
This ordering complements the forwarding-priority table above: ACKs and PATH returns are time-sensitive and short; direct replies take precedence over flood replies to reduce channel occupancy. Exact ordering and queue depth are implementation-defined.
Packet Deduplication
Before processing or forwarding any packet, nodes check if the packet has
already been seen using hasSeen() with the packet hash from
Section 16. This prevents infinite loops in flood routing.
Once a packet is marked as seen, it is NOT forwarded again even if received via a different route. This is a “first packet wins” approach.
Cross-References
- Section 1: Wire Format — Packet structure
- Section 3: Path — Path encoding
- Section 10: Trace — TRACE routing special case
- Section 16: Packet Hash — Deduplication
Reference Implementation
Mesh::sendFlood()insrc/Mesh.cpp— Flood sendMesh::sendDirect()insrc/Mesh.cpp— Direct sendMesh::sendZeroHop()insrc/Mesh.cpp— Zero-hop sendMesh::routeRecvPacket()insrc/Mesh.cpp— Flood forwardingMesh::removeSelfFromPath()insrc/Mesh.cpp— Path manipulationMesh::onRecvPacket()insrc/Mesh.cpp— Direct forwarding
MeshCore Protocol Specification
Section 18: Companion Protocol
Overview
The companion protocol enables BLE (Bluetooth Low Energy) and serial communication between a MeshCore radio and a companion app (phone, tablet, or computer). It uses a binary framing format over the Nordic UART Service (NUS) BLE profile.
BLE Service
| UUID | Description |
|---|---|
6E400001-B5A3-F393-E0A9-E50E24DCCA9E | Nordic UART Service |
6E400002-... | RX Characteristic (app writes to radio) |
6E400003-... | TX Characteristic (radio notifies app) |
Frame Format
Each companion protocol frame consists of a 1-byte type identifier followed by variable-length data:
[type(1)][data(0-171)]
Maximum frame size is 172 bytes.
Message Types (Radio to App)
| Type | Value | Description |
|---|---|---|
| PACKET_CHANNEL_MSG_RECV | 0x01 | Channel message received |
| PACKET_CONTACT_MSG_RECV | 0x02 | Contact message received |
| PACKET_ADV_RECV | 0x03 | Advertisement received |
| PACKET_CONTACT_MSG_RECV_V3 | 0x04 | Contact message V3 (includes SNR) |
| PACKET_CHANNEL_MSG_RECV_V3 | 0x05 | Channel message V3 (includes SNR) |
Command Types (App to Radio)
| Command | Value | Description |
|---|---|---|
| CMD_APP_START | 0x01 | Initialize connection |
| CMD_DEVICE_QUERY | 0x02 | Query device info |
| CMD_SET_CHANNEL | 0x03 | Set active channel |
| CMD_SEND_CHANNEL_MESSAGE | 0x04 | Send message to channel |
| CMD_SEND_CONTACT_MESSAGE | 0x05 | Send message to contact |
| CMD_GET_CONTACTS | 0x06 | Get contact list |
| CMD_GET_CHANNELS | 0x07 | Get channel list |
| CMD_SET_TIME | 0x08 | Set device time |
| CMD_ADD_CONTACT | 0x09 | Add a contact |
| CMD_ADD_CHANNEL | 0x0A | Add a channel |
| CMD_SEND_LOGIN | 0x0B | Login to room/repeater |
| CMD_GET_SETTINGS | 0x0C | Get device settings |
| CMD_SET_SETTINGS | 0x0D | Set device settings |
| CMD_REMOVE_CONTACT | 0x0E | Remove a contact |
| CMD_REMOVE_CHANNEL | 0x0F | Remove a channel |
| CMD_SHARE_CONTACT | 0x10 | Share contact info |
| CMD_SET_ADV_NAME | 0x11 | Set advertisement name |
| CMD_REBOOT | 0x12 | Reboot device |
| CMD_SEND_RAW | 0x13 | Send raw packet |
Connection Sequence
- Scan for BLE devices advertising the Nordic UART Service.
- Connect and discover services.
- Enable notifications on the TX characteristic.
- Send
CMD_APP_STARTto initialize. - Send
CMD_DEVICE_QUERYto get device info. - Send
CMD_SET_TIMEto synchronize clock. - Send
CMD_GET_CONTACTSandCMD_GET_CHANNELSto fetch state.
Byte Order
All multi-byte integers in the companion protocol are little-endian, except for CayenneLPP sensor data which uses big-endian.
Message Length Limit
Text messages are limited to 133 characters.
MTU Considerations
The default BLE MTU is 23 bytes (20 bytes of payload). For complex operations, implementations SHOULD request a larger MTU (up to 512 bytes).
Cross-References
- Section 1: Wire Format — Underlying packet format
- Test vectors:
corpus/companion/
Reference Implementation
docs/companion_protocol.mdin the MeshCore repository
MeshCore Protocol Specification
Section 19: KISS Modem Protocol
Overview
The KISS (Keep It Simple, Stupid) modem protocol provides a serial interface to MeshCore LoRa radios. It follows the KA9Q/K3MC KISS TNC specification with MeshCore-specific SetHardware extensions for cryptographic operations, radio control, and telemetry.
Serial Configuration
- Baud rate: 115200
- Data bits: 8
- Parity: None
- Stop bits: 1
- Flow control: None
Frame Format
┌──────┬───────────┬──────────────┬──────┐
│ FEND │ Type Byte │ Data (escaped)│ FEND │
│ 0xC0 │ 1 byte │ 0-510 bytes │ 0xC0 │
└──────┴───────────┴──────────────┴──────┘
Special Bytes
| Byte | Name | Value | Description |
|---|---|---|---|
| FEND | Frame End | 0xC0 | Frame delimiter |
| FESC | Frame Escape | 0xDB | Escape character |
| TFEND | Transposed FEND | 0xDC | FESC + TFEND represents 0xC0 in data |
| TFESC | Transposed FESC | 0xDD | FESC + TFESC represents 0xDB in data |
Byte Stuffing
To send a data byte of 0xC0 (FEND), emit: 0xDB 0xDC (FESC TFEND). To send a data byte of 0xDB (FESC), emit: 0xDB 0xDD (FESC TFESC).
Type Byte
Bit: 7 6 5 4 3 2 1 0
+---+---+---+---+---+---+---+---+
| Port (4) | Command (4) |
+---+---+---+---+---+---+---+---+
Port is 0 for single-port TNC (standard for MeshCore).
Standard KISS Commands (Host to TNC)
| Command | Value | Data | Description |
|---|---|---|---|
| DataFrame | 0x00 | Raw packet | Queue packet for transmission |
| TXDELAY | 0x01 | 1 byte | Transmitter keyup delay (× 10ms) |
| Persistence | 0x02 | 1 byte | CSMA persistence (0-255) |
| SlotTime | 0x03 | 1 byte | CSMA slot interval (× 10ms) |
| TXtail | 0x04 | 1 byte | Post-TX hold time (× 10ms) |
| FullDuplex | 0x05 | 1 byte | 0=half, nonzero=full |
| SetHardware | 0x06 | Sub-cmd + data | MeshCore extensions |
| Return | 0xFF | — | Exit KISS mode (no-op) |
TNC to Host
| Type | Value | Data | Description |
|---|---|---|---|
| DataFrame | 0x00 | Raw packet | Received packet from radio |
Data frames carry raw MeshCore packets (up to 255 bytes, MAX_TRANS_UNIT).
SetHardware Extensions (0x06)
MeshCore extends the KISS protocol via the SetHardware command. The first data byte is a sub-command identifier.
Request Sub-commands (Host to TNC):
| Sub-cmd | Value | Data | Description |
|---|---|---|---|
| GetIdentity | 0x01 | — | Get node’s Ed25519 public key |
| GetRandom | 0x02 | len(1) | Get random bytes (1-64) |
| VerifySignature | 0x03 | pubkey(32)+sig(64)+data | Verify Ed25519 signature |
| SignData | 0x04 | data | Ed25519 sign |
| EncryptData | 0x05 | key(32)+plaintext | AES-128 encrypt |
| DecryptData | 0x06 | key(32)+mac(2)+ciphertext | AES-128 decrypt with MAC |
| KeyExchange | 0x07 | remote_pub(32) | X25519 ECDH |
| Hash | 0x08 | data | SHA-256 hash |
| SetRadio | 0x09 | freq(4)+bw(4)+sf(1)+cr(1) | Set radio parameters |
| SetTxPower | 0x0A | power(1) | Set TX power (dBm) |
| GetRadio | 0x0B | — | Get radio parameters |
| GetTxPower | 0x0C | — | Get TX power |
| GetCurrentRssi | 0x0D | — | Get current RSSI |
| IsChannelBusy | 0x0E | — | Check if channel busy |
| GetAirtime | 0x0F | pkt_len(1) | Estimate air time |
| GetNoiseFloor | 0x10 | — | Get noise floor |
| GetVersion | 0x11 | — | Get firmware version |
| GetStats | 0x12 | — | Get RX/TX statistics |
| GetBattery | 0x13 | — | Get battery voltage |
| GetMCUTemp | 0x14 | — | Get MCU temperature |
| GetSensors | 0x15 | perms(1) | Get sensor data |
| GetDeviceName | 0x16 | — | Get device name |
| Ping | 0x17 | — | Ping |
| Reboot | 0x18 | — | Reboot device |
| SetSignalReport | 0x19 | enable(1) | Enable/disable RxMeta |
| GetSignalReport | 0x1A | — | Get signal report status |
Response Sub-commands (TNC to Host):
Response codes: response = command | 0x80
| Sub-cmd | Value | Data |
|---|---|---|
| Identity | 0x81 | pubkey(32) |
| Random | 0x82 | random_bytes(1-64) |
| Verify | 0x83 | result(1): 0=invalid, 1=valid |
| Signature | 0x84 | signature(64) |
| Encrypted | 0x85 | mac(2)+ciphertext |
| Decrypted | 0x86 | plaintext |
| SharedSecret | 0x87 | secret(32) |
| HashResult | 0x88 | hash(32) |
| Radio | 0x8B | freq(4)+bw(4)+sf(1)+cr(1) |
| TxPower | 0x8C | power(1) |
| CurrentRssi | 0x8D | rssi(1, signed) |
| ChannelBusy | 0x8E | busy(1): 0=clear, 1=busy |
| Airtime | 0x8F | millis(4) |
| NoiseFloor | 0x90 | dBm(2, signed) |
| Version | 0x91 | version(1)+reserved(1) |
| Stats | 0x92 | rx(4)+tx(4)+errors(4) |
| Battery | 0x93 | millivolts(2) |
| MCUTemp | 0x94 | temp(2, signed, tenths °C) |
| Sensors | 0x95 | CayenneLPP data |
| DeviceName | 0x96 | name(UTF-8) |
| Pong | 0x97 | — |
| SignalReport | 0x9A | status(1) |
| OK | 0xF0 | — |
| Error | 0xF1 | error_code(1) |
| TxDone | 0xF8 | result(1): 0=fail, 1=success |
| RxMeta | 0xF9 | snr(1)+rssi(1) |
Error Codes
| Code | Value | Description |
|---|---|---|
| InvalidLength | 0x01 | Request data too short |
| InvalidParam | 0x02 | Invalid parameter value |
| NoCallback | 0x03 | Feature not available |
| MacFailed | 0x04 | MAC verification failed |
| UnknownCmd | 0x05 | Unknown sub-command |
| EncryptFailed | 0x06 | Encryption failed |
Unsolicited Events
TxDone (0xF8): Sent after packet transmission. 0x01 = success, 0x00 = fail.
RxMeta (0xF9): Sent after each received data frame. Contains:
- SNR: 1 byte, signed, × 4 for 0.25 dB precision
- RSSI: 1 byte, signed, dBm
Data Format Notes
- Maximum payload per frame: 255 bytes (MAX_TRANS_UNIT)
- Frames larger than 255 unescaped bytes are silently dropped
- All multi-byte integers are little-endian
- Radio frequency is in Hz (e.g., 869618000)
- Battery voltage in millivolts
- MCU temperature in tenths of °C (e.g., 253 = 25.3°C)
Cross-References
- Section 1: Wire Format — Packet format within data frames
- Section 14: Cryptography — Crypto operations via SetHardware
- Test vectors:
corpus/kiss/
Reference Implementation
docs/kiss_modem_protocol.mdin the MeshCore repository
MeshCore Protocol Specification
Section 20: Bridge Protocol
Overview
The bridge protocol enables MeshCore packet forwarding over non-LoRa transports such as RS232 serial links and ESP-NOW WiFi. It adds a simple framing layer with a magic number and checksum around the raw MeshCore packet.
Frame Format
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ Magic (2B) │ Payload │ Checksum (2B)│ │
│ 0xC03E │ (variable) │ Fletcher-16 │ │
└──────────────┴──────────────┴──────────────┴──────────────┘
Fields
| Field | Size | Type | Description |
|---|---|---|---|
| Magic | 2 bytes | uint16 | 0xC03E — identifies the frame as a MeshCore bridge packet |
| Payload | variable | raw | Raw MeshCore packet (as produced by Packet::writeTo()) |
| Checksum | 2 bytes | Fletcher-16 | Fletcher-16 checksum over the payload |
RS232 Variant
The RS232 variant adds a length field between the magic and payload:
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ Magic (2B) │ Length (2B) │ Payload │ Checksum (2B)│
│ 0xC03E │ uint16_le │ (variable) │ Fletcher-16 │
└──────────────┴──────────────┴──────────────┴──────────────┘
| Field | Size | Type | Description |
|---|---|---|---|
| Length | 2 bytes | uint16_le | Length of the payload in bytes |
Fletcher-16 Checksum
The Fletcher-16 checksum is computed over the payload bytes:
function fletcher16(data):
sum1 = 0
sum2 = 0
for byte in data:
sum1 = (sum1 + byte) mod 255
sum2 = (sum2 + sum1) mod 255
return (sum2 << 8) | sum1
ESP-NOW Variant
For ESP-NOW transport, the frame format is the same as the basic format (magic + payload + checksum) without the length field, since ESP-NOW provides its own length framing.
Cross-References
- Section 1: Wire Format — Packet format within payload
Reference Implementation
- Bridge handling in MeshCore firmware (board-specific implementations)