2/RF
Network Protocol (RF Network Layer)
Core Protocol Specification Part 1 - The Network Protocol (RF Network Layer)
- Status:raw
- Lead(s):enot
Metadata
| Detail | Description |
| Origin | Derived from ZephCore firmware implementation, cross-referenced with MeshCore reference firmware and documentation. |
| Scope | This document defines The Network Protocol — the RF network protocol used by nodes to communicate over a LoRa mesh. It specifies packet format, routing, payload structures, and cryptography. |
| Companion document | The host-side protocol used by applications to control and query a node over a local transport (serial/USB, Bluetooth Low Energy, or TCP) is defined separately in 3/HOST. This document and the Companion Protocol document share the conventions defined in §1. |
Preface
This document is written to be implementation-agnostic. Field names, constants, and structures are described in neutral terms, not tied to any specific language or firmware. Where pseudo-code is provided, it is illustrative only.1. Conventions and Terminology
1.1. Notation
0xprefix denotes hexadecimal. Example:0x80= 128 decimal.0bprefix denotes binary. Example:0b0110= 6 decimal.- Bit positions count from the least-significant bit as bit 0. A byte
0bABCDEFGHhasAas bit 7 andHas bit 0. - Byte ranges are inclusive on both ends. “bytes 4–7” means four bytes at offsets 4, 5, 6, 7.
- Field sizes are expressed in bytes unless noted otherwise.
1.2. Byte Order
- All multi-byte integer fields are little-endian unless explicitly stated otherwise.
- Cryptographic outputs (hashes, signatures, MACs, public keys) are byte sequences and are not re-ordered — they are transmitted in the order produced by the underlying algorithm.
- The CayenneLPP format used for sensor data is the exception: it is big-endian (per the CayenneLPP specification).
1.3. Strings
- All textual fields (names, messages, passwords) are UTF-8 encoded.
- Fixed-size text fields are padded with null bytes (
0x00) on the right when shorter than the field size. - Variable-length text fields at the end of a payload are not null-terminated on the wire; their length is implied by the remaining payload length.
1.4. Terminology
- Node — any device participating in the mesh.
- Contact — a known peer node, identified by its Ed25519 public key.
- Advert — an advertisement packet broadcast by a node to announce its presence.
- Path — an ordered list of node hashes representing a route through the mesh.
- Node hash — a short prefix of a node’s public key used for routing. One, two, or three bytes; the length is selected per-packet.
- Channel hash — a one-byte identifier for a group channel, derived from the channel secret.
- Direct routing — the packet travels along a pre-known path, hop-by-hop.
- Flood routing — the packet is rebroadcast by every node that hears it (with deduplication).
- Transport codes — optional 4-byte region/scope identifier that filters which nodes will rebroadcast.
- MAC — a 2-byte HMAC-SHA256 prefix used as a message authentication code.
- MTU — Maximum Transmission Unit; the largest physical-layer payload the radio can carry.
- Companion — a host application (phone, PC, embedded controller) that manages a node over a local link.
- Push notification (or “push”) — an unsolicited message from the node to the companion.
1.5. Protocol Layering
A compliant implementation consists of three layers:
┌─────────────────────────────────────────────────────┐
│ Application (contacts, channels, messaging, UI) │
├─────────────────────────────────────────────────────┤
│ The Companion Protocol (host ↔ node) │ ← Part 2 (companion document)
├─────────────────────────────────────────────────────┤
│ The Protocol (RF network) │ ← this document (Part 1)
├─────────────────────────────────────────────────────┤
│ Physical Layer (LoRa radio) │
└─────────────────────────────────────────────────────┘
The Protocol and the Companion Protocol are independent. A node that speaks only The Protocol (e.g., a repeater with no host interface) is compliant. A host library that speaks only the Companion Protocol (without touching the radio) is compliant. The two are bridged by the node firmware.
2. The Protocol (RF Network Layer)
This part specifies the packet format, routing, payload structures, and cryptography used by nodes to communicate over the LoRa RF mesh.
2.1. Packet Structure
Every packet transmitted over the air has the following layout:
┌──────────┬─────────────────────┬─────────────┬──────┬──────────┐
│ header │ transport_codes │ path_length │ path │ payload │
│ 1 byte │ 4 bytes (optional) │ 1 byte │ V │ V │
└──────────┴─────────────────────┴─────────────┴──────┴──────────┘
| Field | Size | Notes |
|---|---|---|
header | 1 byte | Encodes route type, payload type, and payload version. |
transport_codes | 4 bytes (optional) | Present only when route type is a “transport” variant (§2.4). |
path_length | 1 byte | Encodes hop count and per-hash size (§2.5). |
path | variable | Zero or more node hashes (§2.5). May be empty. |
payload | variable | Payload-type-specific data (§2.6). |
2.1.1. Size Limits
| Constant | Value | Description |
|---|---|---|
MAX_PACKET_PAYLOAD | 184 bytes | Maximum payload length. |
MAX_PATH_SIZE | 64 bytes | Maximum total bytes in the path field. |
MAX_TRANS_UNIT | 255 bytes | Maximum total on-wire packet size (physical-layer FIFO size). |
Receivers MUST drop any packet whose payload length exceeds MAX_PACKET_PAYLOAD or whose total length exceeds MAX_TRANS_UNIT. Receivers MUST drop packets whose path byte-length exceeds MAX_PATH_SIZE.
2.2. Header Encoding
The header byte packs three fields:
bit 7 6 5 4 3 2 1 0
│ │ │ │ │ │
└─┬─┘ └─────┬─────┘ └─┬─┘
│ │ │
Payload Payload Route
Version Type Type
(2 bits) (4 bits) (2 bits)
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–1 | 0x03 | Route Type | See §2.3. |
| 2–5 | 0x3C | Payload Type | See §2.6. |
| 6–7 | 0xC0 | Payload Version | Protocol versioning of the payload wrapper. |
2.2.1. Payload Version
The current specification defines only version 1. Versions 2–4 are reserved.
| Value | Version | Meaning |
|---|---|---|
0b00 | 1 | 1-byte source/destination node hashes, 2-byte MAC. |
0b01 | 2 | Reserved for future use (e.g., 2-byte hashes, 4-byte MAC). |
0b10 | 3 | Reserved. |
0b11 | 4 | Reserved. |
Nodes implementing this specification MUST emit version 1 only. Nodes MUST ignore packets whose payload version is unknown.
2.2.2. Sentinel Header Value
The value 0xFF for the entire header byte is reserved as a local in-memory “do not retransmit” marker. It MUST NOT appear on the wire. Receivers that see 0xFF on the wire MUST drop the packet.
2.3. Route Types
The low 2 bits of header specify how the packet is routed.
| Value | Name | Description |
|---|---|---|
0x00 | ROUTE_TYPE_TRANSPORT_FLOOD | Flood routing, filtered by a transport-code scope. |
0x01 | ROUTE_TYPE_FLOOD | Flood routing (no scope). |
0x02 | ROUTE_TYPE_DIRECT | Direct routing along an explicit path. |
0x03 | ROUTE_TYPE_TRANSPORT_DIRECT | Direct routing, filtered by a transport-code scope. |
- Flood packets are rebroadcast by every node that hears them, subject to deduplication and forwarding policy.
- Direct packets are rebroadcast only by the node whose hash matches the next entry in the path.
- Transport variants include a 4-byte
transport_codesfield (§2.4) used to scope forwarding to a region or subnetwork. - A zero-hop packet is a direct-routed packet with
path_length = 0; it is not forwarded at all.
2.4. Transport Codes
When the route type is ROUTE_TYPE_TRANSPORT_FLOOD or ROUTE_TYPE_TRANSPORT_DIRECT, the header is immediately followed by four bytes:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 2 | transport_code_1 | Primary scope code (unsigned 16-bit, little-endian). |
| 2 | 2 | transport_code_2 | Secondary scope code (reserved; unused in v1). |
2.4.1. Transport Code Derivation
A transport code is computed from a 16-byte transport key and the packet’s payload:
message = payload_type_byte || payload
hmac = HMAC-SHA256(key = transport_key, data = message)
code = (hmac[0]) | (hmac[1] << 8) # little-endian 16-bit
if code == 0x0000: code = 0x0001 # reserved
if code == 0xFFFF: code = 0xFFFE # reserved
The codes 0x0000 and 0xFFFF are reserved and MUST NOT appear on the wire; implementations MUST coerce them to 0x0001 and 0xFFFE respectively.
A node that does not share a given transport key cannot verify a matching transport code and therefore MUST NOT forward transport-scoped packets whose code does not match any of its loaded transport keys.
transport_code_2 is reserved; senders MUST set it to 0x0000 and receivers MUST accept any value without treating it as a match criterion.
2.5. Path Encoding
The path_length byte encodes two fields:
bit 7 6 5 4 3 2 1 0
│ │ │ │ │ │ │ │
└─┬─┘ └───────┬───────────┘
│ │
Hash Size Hop Count
Code (2 bits) (6 bits, 0–63)
| Bits | Field | Meaning |
|---|---|---|
| 0–5 | Hop Count | Number of path hashes present (0 to 63). |
| 6–7 | Hash Size Code | Per-hash byte size, minus 1. |
| Hash Size Code | Per-hash Size | Notes |
|---|---|---|
0b00 | 1 byte | Legacy; default. |
0b01 | 2 bytes | Supported. |
0b10 | 3 bytes | Supported. |
0b11 | (reserved) | Invalid — drop packet. |
The on-wire path field is exactly hop_count × hash_size bytes. Receivers MUST compute the byte length from these two values; the path_length byte is NOT a raw byte count.
2.5.1. Path Examples
path_length | Hash Size | Hop Count | path byte length |
|---|---|---|---|
0x00 | 1 | 0 | 0 |
0x05 | 1 | 5 | 5 |
0x45 | 2 | 5 | 10 |
0x8A | 3 | 10 | 30 |
2.5.2. Validity
A path_length byte is valid if and only if:
- The hash size code is not
0b11, and hop_count × hash_size ≤ MAX_PATH_SIZE(64).
Receivers MUST drop packets with invalid path_length.
2.5.3. Node Hash Derivation
A node hash of size N is the first N bytes of the node’s Ed25519 public key. All nodes in a given path-hash-mode deployment MUST use the same hash size for a packet’s path field.
2.6. Payload Types
Bits 2–5 of header specify the payload type. The payload structure for each type is defined in sections 2.8–2.16.
| Value | Name | Description |
|---|---|---|
0x00 | PAYLOAD_TYPE_REQ | Encrypted request to a known peer. |
0x01 | PAYLOAD_TYPE_RESPONSE | Encrypted response to a REQ or ANON_REQ. |
0x02 | PAYLOAD_TYPE_TXT_MSG | Encrypted text message. |
0x03 | PAYLOAD_TYPE_ACK | Acknowledgement. |
0x04 | PAYLOAD_TYPE_ADVERT | Signed node advertisement. |
0x05 | PAYLOAD_TYPE_GRP_TXT | Group (channel) text message. |
0x06 | PAYLOAD_TYPE_GRP_DATA | Group (channel) datagram. |
0x07 | PAYLOAD_TYPE_ANON_REQ | Anonymous (unsolicited, signed) request. |
0x08 | PAYLOAD_TYPE_PATH | Path return (route discovery response). |
0x09 | PAYLOAD_TYPE_TRACE | Trace route with per-hop SNR. |
0x0A | PAYLOAD_TYPE_MULTIPART | Multi-packet composite (currently, multi-ack). |
0x0B | PAYLOAD_TYPE_CONTROL | Unencrypted control data. |
0x0C | (reserved) | |
0x0D | (reserved) | |
0x0E | (reserved) | |
0x0F | PAYLOAD_TYPE_RAW_CUSTOM | Raw bytes with caller-defined encryption. |
2.7. Cryptography
The following primitives are used in The Protocol:
| Purpose | Algorithm |
|---|---|
| Node identity, signing | Ed25519 |
| Key agreement | X25519 (performed on the Ed25519 keypair’s Montgomery-form equivalent, as in ed25519_key_exchange) |
| Symmetric encryption | AES-128 in ECB mode with zero padding |
| Message authentication code | HMAC-SHA256, truncated to the first 2 bytes |
| Hashing | SHA-256 |
2.7.1. Identity
Each node has an Ed25519 keypair:
- Public key: 32 bytes.
- Private key: 64 bytes (expanded form). Some implementations store only a 32-byte seed and derive the 64-byte private key on demand; this is permitted.
The node hash used in paths and routing is a 1, 2, or 3-byte prefix of the public key (§2.5.3).
2.7.2. Shared Secret
The shared secret between two nodes is derived by X25519 key exchange on their Ed25519 keypairs:
shared_secret = X25519(my_private_key, their_public_key) # 32 bytes
Both sides produce the same 32-byte secret.
2.7.3. Encryption
AES-128-ECB with zero padding. The first 16 bytes of the 32-byte shared secret are used as the AES key.
Plaintext is padded with zero bytes up to a multiple of 16. Ciphertext length equals padded plaintext length.
aes_key = shared_secret[0..16] # first 16 bytes
padded = plaintext || zero_pad_to_multiple_of(16)
ciphertext = AES-128-ECB-encrypt(aes_key, padded)
Decryption reverses the process. The receiver cannot distinguish trailing zero bytes added by padding from trailing zero bytes in the original plaintext; application payloads either carry an internal length field or tolerate trailing zeros.
2.7.4. Message Authentication Code (MAC)
The MAC is the first 2 bytes of HMAC-SHA256 computed over the ciphertext, using the full 32-byte shared secret as the HMAC key.
mac = HMAC-SHA256(key = shared_secret, data = ciphertext)[0..2]
Wire format for an encrypted region is mac || ciphertext (MAC first, then ciphertext). Receivers MUST verify the MAC before decrypting; packets with mismatched MACs MUST be silently dropped.
Note on the HMAC key: The HMAC key uses the full 32-byte shared secret, even though the AES key uses only the first 16 bytes. Implementations that truncate the HMAC key to 16 bytes will not interoperate.
2.7.5. Channel Key Derivation
A group channel has a symmetric secret. The secret is either 16 or 32 bytes:
- A 16-byte secret is used both as the AES key and as the HMAC key (zero-padded to 32 bytes implicitly by HMAC processing).
- A 32-byte secret uses its first 16 bytes as the AES key and the full 32 bytes as the HMAC key.
The 1-byte channel hash used on the wire is:
channel_hash = SHA-256(channel_secret)[0] # first byte
Where channel_secret is the raw 16-byte (or 32-byte) secret bytes.
2.7.6. Well-Known Channel Keys
- Public channel: a well-known 16-byte key shared by all devices for the default open channel. The canonical value is defined by the ecosystem; see reference implementations.
- Hashtag channel for a name
#tag: the first 16 bytes ofSHA-256("#tag"). - Private channel: a randomly generated 16-byte secret shared out-of-band.
2.8. Advertisement Payload
Payload type PAYLOAD_TYPE_ADVERT (0x04). Broadcast by every node to announce its presence. The payload is not encrypted but is signed by the advertising node.
┌───────────────┬───────────┬───────────┬────────────┐
│ public_key │ timestamp │ signature │ app_data │
│ 32 bytes │ 4 bytes │ 64 bytes │ 0–32 bytes │
└───────────────┴───────────┴───────────┴────────────┘
| Field | Size | Description |
| ------------ | ------------- | --------------------------------------------------------- | --- | --------- | --- | ---------- |
| public_key | 32 | The advertising node’s Ed25519 public key. |
| timestamp | 4 | Unix timestamp (seconds, unsigned 32-bit, little-endian). |
| signature | 64 | Ed25519 signature covering public_key | | timestamp | | app_data. |
| app_data | 0 to 32 bytes | Optional application-level metadata. See below. |
The maximum app_data length is MAX_ADVERT_DATA_SIZE = 32 bytes. Receivers MUST clip app_data to 32 bytes if the payload length suggests otherwise.
2.8.1. Signature Verification
A receiver verifies the signature by computing:
message = public_key || timestamp || app_data
valid = Ed25519-verify(signature, message, public_key)
Receivers MUST drop adverts with invalid signatures.
2.8.2. app_data Structure
When present, app_data begins with a flags byte that indicates which optional fields follow:
┌───────┬───────────┬───────────┬──────────┬──────────┬─────────────────┐
│ flags │ latitude │ longitude │ feature1 │ feature2 │ name │
│ 1 byte│ 4 (opt) │ 4 (opt) │ 2 (opt) │ 2 (opt) │ 0–N bytes (opt) │
└───────┴───────────┴───────────┴──────────┴──────────┴─────────────────┘
Fields appear in the listed order; each is present if and only if the corresponding flag bit is set. The name field, if present, fills the remainder of app_data.
2.8.3. flags Byte
The flags byte is split into a 4-bit node type (low nibble) and 4 presence flag bits (high nibble).
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–3 | 0x0F | Node Type | See table below. |
| 4 | 0x10 | Has Location | latitude and longitude are present. |
| 5 | 0x20 | Has Feature 1 | feature1 is present. Reserved; currently unused. |
| 6 | 0x40 | Has Feature 2 | feature2 is present. Reserved; currently unused. |
| 7 | 0x80 | Has Name | name is present. |
Node Type values (low 4 bits):
| Value | Name | Description |
|---|---|---|
0x00 | ADV_TYPE_NONE | Unspecified / generic node. |
0x01 | ADV_TYPE_CHAT | Interactive chat node (phone companion). |
0x02 | ADV_TYPE_REPEATER | Store-and-forward repeater. |
0x03 | ADV_TYPE_ROOM | Room server (multi-user chat host). |
0x04 | ADV_TYPE_SENSOR | Sensor node (telemetry publisher). |
0x05–0x0F | (reserved) |
Clarification: Earlier documentation listed the node-type values among the presence flag bits, which is incorrect. They occupy the low 4 bits of the flags byte. The high 4 bits are the presence flags.
2.8.4. Location Encoding
If the Has Location flag is set:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | latitude | Signed 32-bit integer, little-endian. degrees × 1,000,000. |
| 4 | 4 | longitude | Signed 32-bit integer, little-endian. degrees × 1,000,000. |
Example: latitude = 49123456 encodes 49.123456°.
2.8.5. Name
If the Has Name flag is set, all remaining bytes of app_data are the UTF-8 node name. There is no null terminator on the wire.
2.9. Direct-Encrypted Payloads (Request / Response / Text / Path)
Payload types PAYLOAD_TYPE_REQ (0x00), PAYLOAD_TYPE_RESPONSE (0x01), PAYLOAD_TYPE_TXT_MSG (0x02), and PAYLOAD_TYPE_PATH (0x08) share a common wrapper:
┌──────────────────┬─────────────┬──────┬────────────┐
│ destination_hash │ source_hash │ mac │ ciphertext │
│ 1 byte │ 1 byte │ 2 │ variable │
└──────────────────┴─────────────┴──────┴────────────┘
| Field | Size | Description |
|---|---|---|
destination_hash | 1 | First byte of the destination node’s public key. |
source_hash | 1 | First byte of the source node’s public key. |
mac | 2 | MAC of ciphertext (§2.7.4). |
ciphertext | V | AES-encrypted plaintext (§2.7.3). The plaintext structure is payload-type-specific. |
The shared secret used for encryption and MAC is derived between the source and destination via X25519 (§2.7.2).
2.9.1. Receiver Processing
When a node receives a packet with one of these payload types:
- If
destination_hashdoes not match any of the node’s own hashes, treat as not-for-me (but may still forward per routing rules). - For each known peer whose
source_hashmatches, derive the shared secret, verify the MAC, decrypt. If MAC verification succeeds, process the plaintext. - If no peer matches or no MAC verifies, the packet is either not for this node or is malformed; treat as not-for-me.
2.9.2. REQ Plaintext (Request)
┌───────────┬───────────────────────┐
│ timestamp │ request_data │
│ 4 bytes │ rest of plaintext │
└───────────┴───────────────────────┘
timestamp— sender’s Unix time in seconds (little-endian).request_data— application-defined request body. The first byte typically encodes a request sub-type.
Common request sub-types (first byte of request_data):
| Value | Name | Description |
|---|---|---|
0x01 | REQ_TYPE_GET_STATUS | Query node status (battery, counters). |
0x02 | REQ_TYPE_KEEP_ALIVE | Maintain a session. |
0x03 | REQ_TYPE_GET_TELEMETRY | Query telemetry (CayenneLPP). |
Other sub-types are application-defined and out of scope for this specification.
2.9.3. RESPONSE Plaintext
┌───────────────────────────────────┐
│ response_data │
│ rest of plaintext │
└───────────────────────────────────┘
Responses are opaque to this layer. The format is determined by the application and the corresponding request.
2.9.4. TXT_MSG Plaintext
┌───────────┬─────────────────┬─────────────────┐
│ timestamp │ txt_type_attempt│ message │
│ 4 bytes │ 1 byte │ rest of plaintxt│
└───────────┴─────────────────┴─────────────────┘
The txt_type_attempt byte packs two fields:
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–1 | 0x03 | Attempt | Retry counter (0–3). |
| 2–7 | 0xFC | Text Type | Shifted right by 2 to produce a 6-bit value. |
Defined text types (after right-shift):
| Value | Name | Message Content |
|---|---|---|
0x00 | TXT_TYPE_PLAIN | UTF-8 plain text message. |
0x01 | TXT_TYPE_CLI_DATA | CLI command text (sent to a node’s command processor). |
0x02 | TXT_TYPE_SIGNED_PLAIN | First 4 bytes are the sender’s public-key prefix, remainder is UTF-8 text. |
2.9.5. PATH Plaintext (Returned Path)
A path-return packet communicates a discovered route back to an originator.
┌─────────────┬──────────┬────────────┬────────────────┐
│ path_length │ path │ extra_type │ extra_payload │
│ 1 byte │ variable │ 1 byte │ variable │
└─────────────┴──────────┴────────────┴────────────────┘
| Field | Size | Description |
|---|---|---|
path_length | 1 | Same encoding as in the packet header (§2.5). |
path | variable | hop_count × hash_size bytes. |
extra_type | 1 | Low 4 bits: another payload type (typically ACK or RESPONSE). 0xFF means “no extra”. |
extra_payload | variable | Content of the bundled extra payload, or 4 random bytes when extra_type = 0xFF. |
When extra_type is 0xFF (no extra), senders MUST follow it with 4 random bytes. This prevents the encrypted block from being a known-plaintext target.
2.10. Anonymous Request Payload
Payload type PAYLOAD_TYPE_ANON_REQ (0x07). Used when the sender is not yet a known contact of the destination; the sender includes their full public key inline so the destination can derive a shared secret.
┌──────────────────┬────────────────┬──────┬────────────┐
│ destination_hash │ sender_pubkey │ mac │ ciphertext │
│ 1 byte │ 32 bytes │ 2 │ variable │
└──────────────────┴────────────────┴──────┴────────────┘
| Field | Size | Description |
|---|---|---|
destination_hash | 1 | First byte of the destination’s public key. |
sender_pubkey | 32 | The sender’s Ed25519 public key. |
mac | 2 | MAC of ciphertext. |
ciphertext | V | AES-encrypted plaintext. |
The destination derives the shared secret via X25519 between its own private key and the inline sender_pubkey, then MAC-verifies and decrypts.
2.10.1. Common Anonymous-Request Plaintext Formats
The plaintext format depends on the use case. Common examples:
Room-server login:
┌───────────┬────────────────┬──────────┐
│ timestamp │ sync_timestamp │ password │
│ 4 bytes │ 4 bytes │ variable │
└───────────┴────────────────┴──────────┘
timestamp— sender time (Unix seconds, little-endian).sync_timestamp— “sync messages since” epoch (Unix seconds, little-endian).password— UTF-8 password for the room.
Repeater / sensor login:
┌───────────┬──────────┐
│ timestamp │ password │
│ 4 bytes │ variable │
└───────────┴──────────┘
Repeater sub-request (regions / owner info / clock-and-status):
┌───────────┬──────────┬────────────────┬────────────┐
│ timestamp │ req_type │ reply_path_len │ reply_path │
│ 4 bytes │ 1 byte │ 1 byte │ variable │
└───────────┴──────────┴────────────────┴────────────┘
Where req_type is 0x01 (regions), 0x02 (owner info), or 0x03 (clock and status). The reply_path uses the same encoding as §2.5.
Other plaintext formats are application-defined.
2.11. Group Payloads
Payload types PAYLOAD_TYPE_GRP_TXT (0x05) and PAYLOAD_TYPE_GRP_DATA (0x06). Used for channel (group) messaging with a pre-shared channel secret.
┌──────────────┬─────┬────────────┐
│ channel_hash │ mac │ ciphertext │
│ 1 byte │ 2 │ variable │
└──────────────┴─────┴────────────┘
| Field | Size | Description |
|---|---|---|
channel_hash | 1 | First byte of SHA-256(channel_secret) (§2.7.5). |
mac | 2 | MAC of ciphertext, keyed by the channel secret. |
ciphertext | V | AES-encrypted plaintext, keyed by the channel secret. |
The maximum plaintext length for group payloads is MAX_PACKET_PAYLOAD − 16 − 3 = 165 bytes (accounting for cipher block rounding, channel_hash, and mac).
2.11.1. GRP_TXT Plaintext
Same format as TXT_MSG plaintext (§2.9.4). The convention is that the message body is of the form sender_name: message_body so receivers can display the sender.
2.11.2. GRP_DATA Plaintext
Application-defined. The first byte typically identifies a data sub-type; the remainder is sub-type-specific data.
2.12. Acknowledgement Payload
Payload type PAYLOAD_TYPE_ACK (0x03).
┌──────────┐
│ ack_hash │
│ 4 bytes │
└──────────┘
| Field | Size | Description |
|---|---|---|
ack_hash | 4 | First 4 bytes of a SHA-256 hash computed over a message-type-dependent buffer (§2.12.1). |
The field is conventionally called a “checksum” in parts of the reference firmware, but it is NOT a CRC — it is the first 4 bytes (not a truncated CRC-32) of a SHA-256 digest, used as a short collision-resistant identifier of the specific message being acknowledged.
2.12.1. ACK Hash Computation
The hash is computed over a buffer whose contents depend on the message type being acknowledged. The recipient or sender’s public key is appended to the hashed data to “salt” the hash — ensuring that different recipients of the same broadcast text produce different ACK hashes, so that senders can tell which peer acknowledged.
For TXT_MSG with txt_type == TXT_TYPE_PLAIN (0x00) or TXT_TYPE_CLI_DATA (0x01):
buffer = timestamp (4 bytes LE)
∥ txt_type_attempt (1 byte)
∥ message_text (UTF-8, no terminator)
∥ sender_public_key (32 bytes)
ack_hash = SHA-256(buffer)[0..4] ; first 4 bytes
CLI-type messages (TXT_TYPE_CLI_DATA) MUST NOT produce an ACK on the wire despite being hashable. The sender does not track an expected hash for CLI messages; only TXT_TYPE_PLAIN triggers the full send/ack lifecycle.
For TXT_MSG with txt_type == TXT_TYPE_SIGNED_PLAIN (0x02):
buffer = timestamp (4 bytes LE)
∥ txt_type_attempt (1 byte)
∥ sender_pubkey_prefix (4 bytes)
∥ message_text (UTF-8, no terminator)
∥ recipient_public_key (32 bytes)
ack_hash = SHA-256(buffer)[0..4] ; first 4 bytes
The sender’s 4-byte public-key prefix that appears at the start of a signed-plain plaintext (§2.9.4) participates in the hash; otherwise the hash input is identical in structure to the plain form.
Salting note. The public key is appended to the message bytes but is NOT transmitted as part of the hash input over the air — it is implicitly known to both sides (sender knows it from its contact record; recipient knows its own key). This turns the 4-byte ACK hash into a message-AND-recipient identifier, enabling the sender to match incoming ACKs even when the same message has been sent to multiple contacts.
2.12.2. Sender Expectations
A sender that emits a TXT_MSG requesting acknowledgement SHOULD:
- Compute the expected
ack_hashusing the public key at send time. NOTE The public key differs betweenTXT_TYPE_PLAINandTXT_TYPE_SIGNED_PLAIN. - Store the expected hash in a pending-ack table, keyed to the recipient contact and a send timestamp.
- On receipt of a
PAYLOAD_TYPE_ACKwhose 4-byteack_hashmatches a pending entry, mark the message as delivered and discard the entry.
The same ACK MAY be received multiple times if the acknowledging peer retransmits (e.g., via multi-ACK, §2.14); senders SHOULD tolerate this and treat only the first arrival as delivery confirmation.
2.12.3. CRC-32 Historical Note
Earlier drafts of this specification described the ack_hash field as a little-endian CRC-32. That was incorrect. The reference firmware (BaseChatMesh.cpp:221-222, 248-249) uses SHA-256(...)[0..4] exclusively. An implementation that computes CRC-32 over the same input buffer will not match any valid ACK and will fail all deduplication checks.
2.13. Trace Payload
Payload type PAYLOAD_TYPE_TRACE (0x09). Used to discover and measure a path through the mesh, collecting a per-hop SNR sample at each forwarding node.
TRACE packets use an unusual wire layout that repurposes fields from the normal packet structure. Three specific oddities distinguish TRACE from every other payload type:
- The header’s
path_lengthbyte is a hop-consumption counter, not an encoded path-length (§2.5). The low 6 bits start at zero and increment by one at each forwarding hop as the packet travels. The top 2 bits of the byte remain zero; the actual per-hash byte size used for the hop sequence is carried in theflagsbyte of the payload instead. - The original hop-hash sequence is appended to the payload, after the 9-byte preamble — not placed in the header’s
pathfield as with other direct-routed payloads. Forwarders never modify these hashes; they remain in the payload for the life of the packet. - The header’s
pathfield accumulates SNR measurements, one signed byte per consumed hop, written at positionpath_lengthimmediately beforepath_lengthis incremented. By the time the packet reaches its final destination, the header’spathbuffer holds the sequence of measured link qualities in traversal order.
2.13.1. Payload Layout
┌─────┬───────────┬───────┬────────────────────┐
│ tag │ auth_code │ flags │ path_hashes │
│ 4 │ 4 │ 1 │ variable (per hop) │
└─────┴───────────┴───────┴────────────────────┘
| Field | Size | Description |
|---|---|---|
tag | 4 | Request identifier chosen by the originator (little-endian). Echoed in the trace result reported to the originator. |
auth_code | 4 | Application-defined authentication code (little-endian). Opaque to this layer. |
flags | 1 | Low 2 bits: path hash size code. Upper 6 bits: reserved, MUST be zero. |
path_hashes | V | The original ordered sequence of hop hashes the originator intends the packet to traverse. hop_count × hash_size bytes. Never modified during forwarding. |
Flags byte:
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–1 | 0x03 | Path Hash Size | path_hash_size = 1 << bits (yielding 1, 2, or 4 bytes per hash). |
| 2–7 | 0xFC | (reserved) | Set to 0. |
Note that the TRACE flags’ path-hash-size encoding (1 << bits, values 1/2/4) differs from the encoding used in the normal packet header’s path_length byte (§2.5), which encodes hash sizes 1/2/3 as code + 1. TRACE’s encoding permits a 4-byte hash size that the normal header cannot represent.
2.13.2. Header Fields for TRACE
The packet header fields adjacent to the payload carry protocol-repurposed meaning:
| Header field | Normal meaning | TRACE meaning |
|---|---|---|
path_length (low 6 bits) | Encoded hop count | Number of hops consumed so far (starts at 0; incremented each forwarding hop). |
path_length (top 2 bits) | Hash size code | MUST be zero for TRACE. The true hash size lives in the payload’s flags byte. |
path field | Ordered hop hashes | Accumulated SNR measurements: one int8 (SNR_dB × 4) per consumed hop, in order. |
The header’s path field is thus written at byte positions (one per consumed hop), not at hash positions, even when path_hash_size > 1. This is valid because each SNR sample is always exactly one byte.
2.13.3. SNR Encoding
Each accumulated SNR sample is a signed 8-bit integer representing measured_SNR_dB × 4 — i.e., 0.25 dB resolution, range approximately −32 to +31.75 dB. The same convention is used by PACKET_STATS and every place else SNR is carried in this protocol.
2.13.4. Trace Forwarding
When a node receives a TRACE packet with route_type == ROUTE_TYPE_DIRECT:
- Parse the payload preamble:
tag,auth_code,flags. Extractpath_sz = flags & 0x03. - Compute
consumed_bytes = header.path_length × (1 << path_sz). This is the byte offset within the payload’spath_hashestrailer of the next hop hash to be checked. - If
consumed_bytes ≥ path_hashes_length, the TRACE has consumed every hop: this node is the final destination. Deliver the trace (hashes + SNRs) to the application layer and do not forward. - Otherwise, compare the
(1 << path_sz)bytes atpath_hashes[consumed_bytes]against this node’s own public-key prefix. If they do not match, drop the packet (this node is not the expected next hop). - If they match, and forwarding policy permits, and the packet’s dedup signature is not already in the “seen” table:
a. Append this node’s measured SNR byte (
(int8)(measured_snr_dB × 4)) to the header’spathbuffer at offsetpath_length. b. Increment the header’spath_lengthby 1. c. Retransmit.
Because only the header fields change during forwarding, the payload (including the original path_hashes) remains byte-stable across hops. This is what makes a TRACE packet useful: at the final destination, the receiver can hand both the original intended path and the measured per-hop SNRs to the application for analysis.
2.13.5. Deduplication Caveat
Because TRACE packets mutate their header fields as they traverse the mesh, a naive packet-signature scheme that ignores the header (§2.17.5) would correctly deduplicate each hop. However, the specification in §2.17.5 explicitly includes path_length in the TRACE signature precisely to prevent the same trace from being forwarded twice along overlapping sub-paths (which could occur if the return path revisits an intermediate node). Implementations MUST follow §2.17.5.
2.13.6. Historical Note
Earlier drafts of this specification stated that “SNR samples are in the payload” and described only a flat payload of tag + auth + flags + snr_samples. That description is inconsistent with the reference firmware, which:
- Keeps the original
path_hashesin the payload (never modified by forwarders). - Places the accumulated SNR bytes in the header
path[]buffer, not the payload. - Uses the header
path_lengthfield as a hop-consumption counter, not as its §2.5 encoded form.
The layout documented above reflects the actual wire behaviour of the reference firmware (see Mesh.cpp:41-65, Mesh.cpp:684-698).
2.14. Multipart Payload
Payload type PAYLOAD_TYPE_MULTIPART (0x0A). A composite payload that encapsulates another payload plus a “remaining” counter. Currently used only for multi-ACK bursts.
┌─────────────────────────┬──────────────────────┐
│ remaining_and_subtype │ sub_payload │
│ 1 byte │ variable │
└─────────────────────────┴──────────────────────┘
The leading byte packs:
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–3 | 0x0F | Sub-type | A payload type value (e.g., ACK). |
| 4–7 | 0xF0 | Remaining | Number of additional bursts expected. |
For a multi-ACK:
- Sub-type =
PAYLOAD_TYPE_ACK(0x03) - Sub-payload = the 4-byte ACK checksum
- Total payload length = 5 bytes
2.15. Control Payload
Payload type PAYLOAD_TYPE_CONTROL (0x0B). Carries unencrypted control data, typically for discovery.
┌───────┬─────────────────┐
│ flags │ data │
│ 1 B │ variable │
└───────┴─────────────────┘
The flags byte has two sub-fields:
| Bits | Mask | Field | Description |
|---|---|---|---|
| 0–3 | 0x0F | Sub-data | Sub-type-specific flag bits. |
| 4–7 | 0xF0 | Sub-type | Identifies the control message sub-type. |
Bit 7 of the flags byte (0x80) additionally signals that the control packet is valid only as a zero-hop direct packet; receivers MUST drop it if the packet’s hop count is not zero.
2.15.1. DISCOVER_REQ (sub-type 0x8)
┌───────┬─────────────┬──────┬──────────────┐
│ flags │ type_filter │ tag │ since │
│ 1 B │ 1 B │ 4 B │ 4 B (opt) │
└───────┴─────────────┴──────┴──────────────┘
flags=0x80|0x01if prefix-only reply is requested.type_filter— bitfield. Bitnset means the sender wants responses fromADV_TYPE_nnodes.tag— random identifier, reflected in responses.since— optional 4-byte Unix timestamp; responders with a last-advert-time earlier than this SHOULD NOT respond. Omitted means 0.
2.15.2. DISCOVER_RESP (sub-type 0x9)
┌───────┬──────┬──────┬──────────────────┐
│ flags │ snr │ tag │ pubkey │
│ 1 B │ 1 B │ 4 B │ 8 or 32 B │
└───────┴──────┴──────┴──────────────────┘
flags— high nibble =0x9, low nibble = responder’sADV_TYPE_*.snr— signed 8-bit SNR of the request packet (SNR × 4, 0.25 dB units).tag— echoed from the request.pubkey— 32-byte full public key, or 8-byte prefix if the request’s prefix-only bit was set.
2.16. Raw Custom Payload
Payload type PAYLOAD_TYPE_RAW_CUSTOM (0x0F). The payload is opaque to the network layer; the application is responsible for any framing, encryption, and authentication. Raw packets are handled only in direct routing mode.
2.17. Routing and Forwarding Rules
2.17.1. Flood Routing
When a node sends a flood packet:
- The route type is set to
ROUTE_TYPE_FLOOD(orROUTE_TYPE_TRANSPORT_FLOODwith appropriate codes). - The sender’s own hash is not appended to
path; thehop_countis 0 when transmitted. - The packet’s deduplication signature is recorded locally.
- The packet is broadcast.
When a node receives a flood packet:
- Validate
path_length. Drop if invalid. - Check the deduplication table (§2.18). If already seen, drop.
- Process the payload (decrypt/verify/etc.) to the extent the node is able.
- If the node’s forwarding policy (§2.17.4) permits forwarding:
a. Append this node’s hash to
path, incrementinghop_count. b. If appending would causehop_count × hash_size > MAX_PATH_SIZE, do not forward. c. Schedule a retransmission after a randomized delay based on estimated airtime.
2.17.2. Direct Routing
Direct-routed packets carry an explicit ordered path of hashes. Each forwarder consumes one entry.
When a node sends a direct packet with a known path of length N:
- Route type =
ROUTE_TYPE_DIRECT(orROUTE_TYPE_TRANSPORT_DIRECT). path= the sequence of hashes from the next hop to the final destination, in order.hop_count=N.
When a node receives a direct packet:
- Validate
path_length. - If
hop_count == 0, this is a zero-hop packet — process only if this node is the intended destination. - If the first entry in
pathmatches this node’s hash: a. Process the payload if addressed to us. b. If forwarding is permitted, remove the first entry frompath(shifting subsequent entries left) and decrementhop_count. c. Retransmit. - Otherwise, do not forward (but may still process the payload if addressed to us — e.g., ACKs sent back along a known path).
2.17.3. Zero-Hop Packets
A zero-hop packet is a direct-routed packet with hop_count = 0. It is intended only for neighbours in direct radio range and is never forwarded.
2.17.4. Forwarding Policy
Each node decides locally whether to forward a given packet. Typical policies:
- Repeater — forwards most packets.
- Room server — forwards packets addressed to its room, adverts, and selected control packets.
- Chat client — does not forward by default; may optionally act as a “client repeater” for a limited frequency range and payload types.
A forwarder MUST respect these hard rules:
- Do not forward a packet whose
path_lengthis invalid. - Do not forward
PAYLOAD_TYPE_TRACEunless the next-hop entry matches this node. - Do not forward a transport-scoped packet (
ROUTE_TYPE_TRANSPORT_*) unless the node has a transport key whose derived code matches the packet’stransport_code_1. - Do not forward a packet whose deduplication signature is already in the “seen” table.
- Respect duty-cycle regulations applicable to the region of operation (e.g., EU ETSI EN 300 220).
2.17.5. Packet Hash (Deduplication Signature)
Every packet has a deduplication signature computed from the payload type and payload content. For most payload types:
signature_input = payload_type_byte || payload
signature = SHA-256(signature_input)[0..8] # first 8 bytes, used as 64-bit hash
For PAYLOAD_TYPE_TRACE, the path length is also included:
signature_input = payload_type_byte || path_length_byte || payload
This ensures TRACE packets with different accumulated SNR paths are not falsely deduplicated.
2.18. Duplicate Suppression
A compliant node MUST maintain a “seen packets” table that retains recent packet signatures (§2.17.5) for long enough to suppress duplicate retransmissions across the mesh.
Recommended policy:
- Table size: enough to hold the signatures of all packets received in the last several seconds under typical load.
- Entry lifetime: a few seconds, tuned to typical mesh diameter × per-hop retransmit delay.
Duplicate suppression is REQUIRED to prevent broadcast storms during flood routing.
Appendix A: Compliance Checklist
A minimally compliant implementation of The Protocol MUST:
- Parse and emit packets per §2.1–§2.6.
- Validate
path_lengthper §2.5.2 and drop invalid packets. - Respect
MAX_PACKET_PAYLOADandMAX_TRANS_UNIT. - Implement deduplication per §2.18.
- Implement Ed25519 signature verification for adverts per §2.8.1.
- Implement X25519 key agreement, AES-128-ECB encryption, HMAC-SHA256 MAC per §2.7.
- Use
PUB_KEY_SIZE = 32bytes for the HMAC key and the first 16 bytes for the AES key (§2.7.4). - Treat
0xFFas an on-wire invalid header and drop such packets (§2.2.2).
Compliance requirements for the Companion Protocol are defined in the companion document (Part 2: The Companion Protocol, Appendix A).
Appendix B: Constants Summary
B.1. Size Constants
| Constant | Value | Units |
|---|---|---|
PUB_KEY_SIZE | 32 | bytes |
PRV_KEY_SIZE | 64 | bytes |
SEED_SIZE | 32 | bytes |
SIGNATURE_SIZE | 64 | bytes |
CIPHER_KEY_SIZE | 16 | bytes |
CIPHER_BLOCK_SIZE | 16 | bytes |
CIPHER_MAC_SIZE | 2 | bytes |
MAX_ADVERT_DATA_SIZE | 32 | bytes |
MAX_PACKET_PAYLOAD | 184 | bytes |
MAX_PATH_SIZE | 64 | bytes |
MAX_TRANS_UNIT | 255 | bytes |
MAX_GROUP_DATA_LENGTH | 165 | bytes |
MAX_TEXT_LEN | 160 | bytes |
The Companion Protocol’s default MAX_FRAME_SIZE (172 bytes) is defined in the companion document (Part 2, Appendix B).
B.2. Route Types
| Value | Name |
|---|---|
0x00 | ROUTE_TYPE_TRANSPORT_FLOOD |
0x01 | ROUTE_TYPE_FLOOD |
0x02 | ROUTE_TYPE_DIRECT |
0x03 | ROUTE_TYPE_TRANSPORT_DIRECT |
B.3. Payload Types
| Value | Name |
|---|---|
0x00 | PAYLOAD_TYPE_REQ |
0x01 | PAYLOAD_TYPE_RESPONSE |
0x02 | PAYLOAD_TYPE_TXT_MSG |
0x03 | PAYLOAD_TYPE_ACK |
0x04 | PAYLOAD_TYPE_ADVERT |
0x05 | PAYLOAD_TYPE_GRP_TXT |
0x06 | PAYLOAD_TYPE_GRP_DATA |
0x07 | PAYLOAD_TYPE_ANON_REQ |
0x08 | PAYLOAD_TYPE_PATH |
0x09 | PAYLOAD_TYPE_TRACE |
0x0A | PAYLOAD_TYPE_MULTIPART |
0x0B | PAYLOAD_TYPE_CONTROL |
0x0F | PAYLOAD_TYPE_RAW_CUSTOM |
B.4. Advert Node Types
| Value | Name |
|---|---|
0x00 | ADV_TYPE_NONE |
0x01 | ADV_TYPE_CHAT |
0x02 | ADV_TYPE_REPEATER |
0x03 | ADV_TYPE_ROOM |
0x04 | ADV_TYPE_SENSOR |
B.5. Text Types
| Value | Name |
|---|---|
0x00 | TXT_TYPE_PLAIN |
0x01 | TXT_TYPE_CLI_DATA |
0x02 | TXT_TYPE_SIGNED_PLAIN |
Companion Protocol command and response codes are tabulated in the companion document (Part 2, Appendix B).
Appendix C: Known Discrepancies Between Documentation and Implementation
This specification reflects the actual on-wire behaviour of a compliant implementation, not prior Core documentation drafts. The following discrepancies were identified and resolved in favour of the implementation:
C.1. Advert Flags Byte (§2.8.3)
Earlier documentation listed the values 0x01 (chat), 0x02 (repeater), 0x03 (room), 0x04 (sensor) alongside the bit-flag masks 0x10, 0x20, 0x40, 0x80 in a single “flags” table, implying they occupied the same field. In reality, the node type occupies the low 4 bits of the flags byte, and the presence flags occupy the high 4 bits. The type and presence flags are decoded independently. This specification clarifies the split.
C.2. HMAC Key Length (§2.7.4)
The HMAC key uses the full 32-byte shared secret, even though the AES key uses only the first 16 bytes of that same secret. A reader of the existing MeshCore docs might reasonably assume a 16-byte HMAC key by symmetry; the implementation does not. Incorrectly truncating the HMAC key to 16 bytes produces MAC verification failures that are hard to diagnose.
C.3. TRACE Packet Wire Layout
Most payload types carry their routing path in the packet header’s path field. TRACE packets do not: the header’s path field is repurposed as an accumulator of measured per-hop SNR bytes, and the original path-hash sequence lives in the payload trailer (after the 9-byte tag/auth/flags preamble). Additionally, the header’s path_length byte is reinterpreted as a hop-consumption counter (its low 6 bits) and the top 2 bits (normally the hash-size code of §2.5) are locked at zero — the TRACE hash size is carried separately in the payload’s flags byte using a distinct encoding (1 << bits → 1/2/4 bytes per hash).
This is substantially different from every other payload type and requires special-case handling by forwarders. See §2.13 for the full specification.
Earlier drafts of this specification described TRACE packets as carrying their SNR samples in the payload. That description does not match the reference firmware; it has been replaced.
C.4. Protocol Version v1 Only
Payload versions 2–4 (header bits 6–7) are reserved but not in use. All current implementations emit and expect version 1.
C.5. ACK Hash Algorithm (§2.12)
Earlier drafts described PAYLOAD_TYPE_ACK as carrying a 4-byte little-endian CRC-32 of the acknowledged message. This was incorrect. The reference firmware (BaseChatMesh.cpp) computes the field as the first 4 bytes of SHA-256 over a message-type-dependent buffer that includes the recipient’s public key as a salt, and transmits those 4 bytes directly (no additional byte-order transformation).
The field name checksum in some firmware comments is a legacy term; it is not a CRC in any standard sense. Implementations of the original CRC-32 description will never produce a matching ACK.
Companion Protocol discrepancies (error codes, stats frames) are documented in the companion document (Part 2, Appendix C).