Skip to content

Channels: hashes, names, PSKs, AES-256-CCM, and pairwise DMs

LowMesh does not carry a small numeric channelId on the wire. Every Mesh PDU includes a channelHash (2 bytes, LE uint16_t) that selects the crypto domain for the inner AES-CCM application tail.

Truncation (normative on this wire revision): compute the full 32-bit digest per the recipes below, then use the lower 16 bits as uint16_t (digest[0] | (digest[1] << 8) in LE order) for channelHash on-air. (Host tools MAY still store/compare 32-bit digests internally; the mesh header carries 16 bits.)

There are three normative ways a channelHash selects keys:

  1. Default community (0x0000) — fixed AES-256 key defaultCommunityPsk32 in ROM on every device (SHA256("LowMesh/v2/defaultCommunityPsk32"), first 32 bytes). The default community domain is encrypted/authenticated against accidental corruption and casual non-LowMesh decoding, but it is not private against other LowMesh devices because the defaultCommunityPsk32 is globally shared. Use named channels, DMs, or admin channels for private traffic. There is no unauthenticated cleartext inner payload on 0x0000 in the current specification.
  2. Named channel — derived from (channelNameUtf8, psk32) (shared group key).
  3. Pairwise DM — derived from HKDF over the X25519 ECDH output — see Network key & channel keys.

What receivers do (normative intent)

  1. Parse the dynamic base header (see Packet layout); preserve ttlRaw at wire offset 0x04 inside the CCM AAD copy with byte-wide zero-masking at that offset (see below).
  2. Read channelHash (16-bit).
  3. Build the candidate list of local psk32 entries whose on-wire channelHash equals the header value (community 0x0000, matching DM sessions, matching named channels, admin channel if applicable).
  4. If more than one candidate maps to the same 16-bit channelHash, try candidates in a deterministic order (document product policy — e.g. DM before named before community) and accept only one successful MIC verification.
  5. If more than one candidate verifies, the frame is ambiguousMUST drop (log DROP_MIC_AMBIGUOUS or equivalent).
  6. If no candidate verifies: treat as unknown hash for relay policy (see Embedded storage DeviceRole::relayCommunityTraffic) — end nodes typically drop; repeaters MAY apply shouldForwardAsThirdPartyRelay.

DM receiver caching

Devices SHOULD maintain a RAM table of DmPairwiseSession entries (see Embedded storage): at minimum peerDeviceId, channelHash, derived psk32, trustState, and the peer’s ed25519PublicKey for re-derivation and UI.

When NODE_ADVERT / NODE_INFO_* delivers a new pkEd_peer, firmware SHOULD precompute (or refresh) the pairwise psk32 and channelHashDM for that peer off the radio hot path where possible.

channelHash definitions (normative digest math)

A) Named channels (group / admin)

SHA-256 digest over UTF8("LMCH/v1") || uint16_le(N) || channelNameUtf8 || psk, then:

channelHash_onWire = uint16_le( digest[0] | (digest[1] << 8) )   // 2-byte LE in mesh header

Practically: channelHash_onWire = digest[0] | (digest[1] << 8) as an unsigned 16-bit value. Host tools MAY cache read_u32_le(digest[0..3]) for UI and collision analysis; the mesh PDU carries 16 bits only.

channelHash MUST NOT be 0 for named channels (the recipe is expected to yield non-zero lower 16 bits; treat accidental 0 as a deployment error).

B) Pairwise direct messages (DM)

Let psk32 be the HKDF-derived AES key for the DM session (see Network key & channel keys §5).

digestDM[0..31] = SHA256( psk32[0..31] )
channelHashDM_onWire = uint16_le( digestDM[0] | (digestDM[1] << 8) )   // 2-byte LE in mesh header

(Provisioning tools MAY compare read_u32_le(digestDM[0..3]) internally; only the lower 16 bits are transmitted on-air.)

16-bit collision policy: Truncation to 16 bits can cause unrelated keys to share the same on-air channelHash. Receivers MUST use the deterministic multi-candidate MIC procedure above. Provisioning tools SHOULD warn when any configured named channel or DM maps to the same 16-bit channelHash as another entry. Devices SHOULD expose a local diagnostic warning when such a collision exists among configured named channels, pairwise DM sessions, or admin channels. MUST NOT expand the mesh header beyond 16-bit channelHash on this wire revision.

C) Default community reserved hash

channelHash = 0x0000 is reserved for the default community key above — not cleartext. A separate true cleartext “Ham operator” profile is deferred to a future wire revision.

AES-256-CCM (all channels)

For every resolved psk32 (0x0000, named, or DM), the MIC length is selected by MIC_MODE in verFlags bits 3–2 (see Packet layout):

MIC_MODEModemicLen
00AES-256-CCM-4 (required, default)4
01AES-256-CCM-8 (optional)8
10 / 11Reservedreceivers MUST drop (DROP_UNSUPPORTED_MIC_MODE)

verFlags bits 1–0 remain reserved and MUST be 0 on TX. Stacks that require all reserved bits to be zero will safely drop MIC_MODE != 00 frames rather than misparse them. This does not mean CCM-4-only devices can decrypt CCM-8 traffic — they cannot.

Receivers MUST drop frames with unsupported MIC_MODE values.

Ciphertext + MIC length (parsing)

Let prefixBytes be the size of the base header plus optional region, fragment, and routing blocks. Let rxPhyLen be the Mesh PDU length (excluding PHY CRC if captured separately).

prefixBytes = baseSize + regionSize + fragSize + routeSize
appTailLen  = rxPhyLen - prefixBytes
micLen      = decodeMicLen(verFlags.MIC_MODE)   // 4 or 8

if appTailLen < micLen:
    drop DROP_TOO_SHORT

cipherLen    = appTailLen - micLen
innerBodyLen = cipherLen
appTail      = ciphertext || MIC
  • cipherLen equals the plaintext length of the inner message (innerBodyLen after decrypt).
  • MIC_MODE changes only the MIC/tag length. It does not change channelHash, nonce construction, AAD masking, routing-block behavior, payloadType, or the dynamic header layout.

Backward compatibility: CCM-8 is backward compatible by safe rejection. Devices that only support required CCM-4 will drop MIC_MODE = 01 frames; they MUST NOT claim to read CCM-8.

CCM-8 policy

  • Normal mesh traffic SHOULD use MIC_MODE = 00 (CCM-4) by default for airtime efficiency.
  • Admin traffic SHOULD use MIC_MODE = 01 (CCM-8) when both sender and receiver advertise SEC_CAP_CCM8 (see Node identity).
  • Products MAY require CCM-8 for destructive or high-risk admin operations. If CCM-8 is required by policy and a device does not support it, the admin command MUST NOT be sent or MUST be rejected (DROP_ADMIN_POLICY_REQUIRES_CCM8 / ADMIN_STATUS_POLICY_REQUIRES_CCM8).
  • A sender SHOULD use CCM-8 only when the intended receiver is known to support SEC_CAP_CCM8. For broadcast/flood traffic, use CCM-4 unless all intended receivers are known to support CCM-8 or product policy intentionally excludes older devices.
  • Admin tools MAY require CCM-8 even if that means older devices cannot be administered.

CCM-8 is an optional stronger MIC mode for admin, high-security, or product-specific deployments. Normal mesh traffic remains CCM-4 by default.

Additional Authenticated Data (AAD) — critical

AAD length is dynamic:

aadLen = baseSize + regionSize + fragSize

Copy those bytes from the received Mesh PDU into the AAD buffer in order:

  1. Base header bytes (verFlagspayloadType, dynamic 16 or 20 bytes depending on HAS_DST_ID).
  2. Spatial Region block (5 bytes: spatialId + spatialMask) if HAS_REGION.
  3. Fragment block (2 bytes) if HAS_FRAGMENT.

The routing block MUST NOT be included in AAD. This allows stateless repeaters to append path hashes and grow the routing block without invalidating the MIC over the encrypted inner message.

Strict source routes vs flood breadcrumbs: The mutable plaintext routing block is appropriate for flood breadcrumbs only. Strict source-route instructions that affect security, authorization, or delivery guarantees MUST be authenticated inside the encrypted application body or by a separate routeAuth mechanism. Strict source routes carried only in the plaintext routing block are advisory unless authenticated by a higher layer — MUST NOT be treated as trusted authorization by themselves. See Routing on wire.

TTL zero-masking: before CCM, set hopControl (wire name ttlRaw) to 0x00 in the AAD copy at the same relative offset as on the wire within the copied base header. For the documented base layout, this byte is always at Mesh PDU offset 0x04. Masking the whole byte (hop limit and HAS_ROUTE_BLOCK bit 7) lets relays set the route flag and decrement hops without mutating verFlags (which are authenticated in AAD).

Plaintext / ciphertext scope

  • Encrypted/authenticated by CCM: the application tail bytes only (all channelHash values including 0x0000).
  • Not encrypted by the channel PSK: base header, optional 5-byte region block, optional fragment block, optional routing block (visible on the wire outside the ciphertext).

AES-256-CCM nonce construction (Base Profile)

AES-CCM nonce construction MUST be unique per psk32. messageId alone is not sufficient — it is only 16 bits. The on-wire sessionId (4 bytes LE in the base header) MUST participate in nonce and replay keying.

Base Profile default (12-byte CCM nonce)

Normative Base Profile layout (exactly 12 bytes):

OffsetSizeField
04srcId (LE nodeId32)
44sessionId (LE uint32, from base header offset 0x09)
82messageId (LE uint16)
102fragmentWord — packed mesh fragment when HAS_FRAGMENT, else 0

Rules:

  • sessionId is mandatory on the wire and MUST be included in the nonce.
  • fragmentWord is 0 when HAS_FRAGMENT is not set.
  • fragmentWord is in the nonce. The 10-bit fragmentIndex MUST NOT be wrapped or reused to represent more than 1024 chunks — see Fragmentation.
  • The same nonce MUST NOT be reused with the same psk32, regardless of MIC_MODE / MIC length.
  • MIC_MODE MUST NOT be added to the nonce unless a future unreleased spec change explicitly does so.
  • Originators MUST persist sessionId in NVM and MUST change it after reboot (or when reuse would collide) before reusing messageId values under the same psk32 — see Embedded storage.

See Base interoperability profile and Golden test vectors.

Integrity expectations

  • LoRa CRC16 provides PHY-level error detection for the whole over-the-air payload.
  • CCM-4 / CCM-8 provides cryptographic integrity for the inner ciphertext under the AAD policy above (MIC length from MIC_MODE).

Replay protection

AES-CCM authenticates payloads but does not by itself prevent replay. Receivers MUST use duplicate / replay suppression keyed by at least:

(channelHash, srcId, sessionId, messageId, fragmentWord)

where fragmentWord is the packed fragment block when present, else 0 (same keying as the duplicate cache in Routing).

Admin / control payloads SHOULD include stronger replay protection inside the encrypted inner body — for example a monotonic command counter, timestamp, session counter, or challenge/response token. Receivers MUST reject stale or repeated admin commands when replay metadata indicates the command was already processed — see Mesh admin.

Admin / operator traffic

Named-channel channelHash with the fixed lowmesh/admin name + adminChannelPsk — see Mesh admin & operator channel.

Relationship to NVM / provisioning

Named channels: firmware stores (channelNameUtf8, psk) — see Network key & channel keys and Embedded storage.

DMs: firmware stores Ed25519 keys in NVM, sessionId, and HKDF-derived psk32 / channelHashDM in RAM after ECDH.

Hardware note (ESP32-S3, nRF52840)

SHA-256, X25519, Ed25519↔X25519 conversion, and AES-CCM SHOULD use optimized library implementations (mbedTLS, TweetNaCl, PSA). Avoid blocking radio tasks on curve math — use deferred workers and bounded session caches.

LowMeshOS — always open-source mesh protocol documentation