Sequence Security
Low Power Nodes
4 Core Roles
Mesh Architecture
What You Will Learn
Bluetooth Mesh turns BLE’s point-to-point radio into a many-to-many publish-subscribe network that can span an entire building. Making that work reliably for years requires smart solutions for four problems: keeping encryption unique over a lifetime of messages (IV Index), letting battery nodes sleep while staying connected (Friendship), defining what each node is capable of (Features), and describing how these nodes connect to each other (Topology).
Each concept below is paired with a hands-on BlueZ/Linux snippet so you can immediately experiment on your Ubuntu dev machine.
Prerequisites: Basic BLE knowledge, familiarity with BlueZ and bluetooth-meshd.
🔑 Key Terms in This Post
The Problem: 24-bit Sequence Numbers Run Out
Every Network PDU in Bluetooth Mesh carries a 24-bit sequence number. That gives each mesh element a budget of 16,777,216 unique values before the counter wraps back to zero.
Why does wrapping matter? The sequence number feeds directly into the security nonce — a value that must never repeat for a given network key. If two PDUs are encrypted with the same nonce, an attacker could break the cipher. At a steady 2 messages per second a node would exhaust its entire sequence space in roughly 97 days.
How the Security Nonce is Assembled:
|
IV Index
32 bits — 4 bytes
Increments each epoch
|
+ |
SEQ Number
24 bits — 3 bytes
Counts per message
|
= |
Security Nonce
Used by AES-CCM
Must never repeat per NetKey
|
The Solution: Extend with a 32-bit IV Index
A 32-bit IV Index is included in every nonce calculation alongside the sequence number. The combined space is so large that even at 2 Hz the network’s cryptographic lifetime extends to billions of years. The IV Index is not transmitted in full every time — only its least significant bit rides in each Network PDU, letting receivers track which of two adjacent IV values was in use.
| Transmission Rate | SEQ alone | SEQ + IV Index |
|---|---|---|
| 2 Hz (normal operation) | ~97 days | Billions of years |
| 24 Hz (during IV Update) | ~8 days | Still billions of years |
IV Update Procedure
When a node detects its sequence number is approaching exhaustion, it broadcasts an IV Update signal to the mesh. The transition from old IV Index (N) to new (N+1) takes a minimum of 8 days so all sleeping or distant nodes have time to synchronise. During the update window, the maximum transmission rate is capped at 24 Hz to prevent the counter outrunning the transition. In practice, respecting the 100 PDU / 10 second guideline means exhaustion typically takes about 19 days.
IV Update State Flow:
|
Normal Operation
IV Index = N
SEQ counting upward Max 100 PDUs / 10 s |
→
SEQ nearing limit
node signals update |
Update In Progress
Min 8 days
IV[N] and IV[N+1] both valid Max 24 Hz transmit rate |
→
All nodes
acknowledged |
Normal Operation
IV Index = N+1
SEQ resets to 0 Full capacity restored |
BlueZ: IV Index in Practice
bluetooth-meshd manages the IV Index automatically. It is written to the node’s persistent JSON config and updated whenever an IV Update beacon is received or initiated.
# Locate node configuration directories managed by bluetooth-meshd
ls ~/.local/share/bluetooth-meshd/
# Inspect a node's stored IV Index (look for "iv_index" key)
cat ~/.local/share/bluetooth-meshd/<node-uuid>/node.json
# Run the daemon in verbose mode to watch IV-related events
sudo bluetooth-meshd --nodetach -l debug 2>&1 | grep -i "iv"
/*
* The security nonce is a 13-byte structure.
* For a Network PDU (nonce type 0x00) its layout is:
*
* Byte 0 : Nonce type (0x00 = Network)
* Byte 1 : CTL | TTL
* Bytes 2-4 : 24-bit SEQ number (big endian)
* Bytes 5-6 : Source address (big endian)
* Bytes 7-8 : Zero padding
* Bytes 9-12: 32-bit IV Index (big endian)
*
* AES-CCM uses this nonce to encrypt/decrypt the PDU payload.
* Uniqueness is guaranteed as long as (IV_Index, SEQ) never repeats
* for the same source address and network key.
*/
struct mesh_net_nonce {
uint8_t type; /* 0x00 — Network nonce */
uint8_t ctl_ttl; /* CTL bit + 7-bit TTL */
uint8_t seq[3]; /* 24-bit sequence number */
uint8_t src[2]; /* Source unicast address */
uint8_t pad[2]; /* 0x00 0x00 */
uint8_t iv_index[4]; /* 32-bit IV Index */
};
Why Friendship Exists
Bluetooth Mesh floods messages across the network — every node either consumes or relays each packet. This requires radios to be on continuously, which is fine for a mains-powered light controller but fatal for a coin-cell sensor that needs years of battery life.
Friendship is the spec’s answer: a mains-powered Friend node acts as a message buffer for a sleeping Low Power Node (LPN). The LPN powers down its radio, wakes at a predetermined interval, collects whatever arrived while it slept, then goes back to sleep.
Friendship Architecture:
|
🔋
Low Power Node
Battery operated
Sleeps between polls Initiates the friendship Can have only ONE friend |
Constraints
Single radio hop only Same subnet required LPN always initiates ← Friend Poll →
← Deliver Msg →
Friend also delivers
security key updates to keep LPN on the network |
🔌
Friend Node
Mains powered
Radio always on Maintains Friend Queue Can serve MULTIPLE LPNs |
The Friend Queue Step-by-Step
Once a friendship is established, the Friend node intercepts all mesh traffic addressed to its LPN partner. It buffers those messages in a dedicated Friend Queue. When the LPN wakes and sends a poll, the Friend delivers messages one by one. The Friend also forwards security updates (key refreshes, IV Updates) so the LPN never falls behind the network’s security state.
| Step | Actor | Action | LPN Radio State |
|---|---|---|---|
| 1 | Any mesh node | Broadcasts a message addressed to the LPN’s unicast address | 💤 Off / Sleeping |
| 2 | Friend Node | Receives, recognises destination belongs to its LPN, stores in Friend Queue | 💤 Off / Sleeping |
| 3 | LPN | Wakes at its poll interval, transmits a Friend Poll message | 📡 Transmitting |
| 4 | Friend Node | Delivers buffered messages from queue to LPN one by one | 📡 Receiving |
| 5 | LPN | Queue empty confirmed, returns to sleep until next poll interval | 💤 Off / Sleeping |
Key Rules at a Glance
BlueZ: Friendship and LPN Configuration
/* Feature bitmask used by BlueZ mesh stack (mesh/net.h) */
#define MESH_FEATURE_RELAY (1 << 0)
#define MESH_FEATURE_PROXY (1 << 1)
#define MESH_FEATURE_FRIEND (1 << 2)
#define MESH_FEATURE_LOW_POWER (1 << 3)
/*
* LPN poll timeout is configured per friendship in BlueZ.
* bluetooth-meshd stores friendship state in the node JSON config.
* The Poll Timeout field (3 bytes) is the maximum time (in 100ms units)
* the Friend waits before assuming the LPN has gone offline.
*
* Example: poll_timeout = 300 → 30 seconds between LPN polls
*/
# Monitor friendship establishment events in real time
sudo bluetooth-meshd --nodetach -l debug 2>&1 | grep -i "friend\|lpn\|poll\|queue"
# Using mesh-cfgclient: check a node's heartbeat / feature state
$ mesh-cfgclient
[mesh-cfgclient]# list-nodes
[mesh-cfgclient]# get-composition 0x0003 0
# Look for the "features" bitmask in the Composition Data response
# Bit 3 set = Low Power feature supported
# Bit 2 set = Friend feature supported
Every mesh node can transmit and receive messages out of the box. On top of that baseline, the spec defines four optional features that determine how a node participates in the network. A single physical device can support any combination of these.
Supports vs. Active: The Important Distinction
A node that supports a feature is not necessarily using it. The naming convention in the spec reflects the active state:
| Feature | Supports feature (may be off) | Feature enabled AND in use | Formal name |
|---|---|---|---|
| Relay | Hardware capable, currently off | Actively rebroadcasting PDUs | Relay node |
| Proxy | Hardware capable, currently off | Actively bridging GATT ↔ ADV | Proxy node |
| Low Power | Always on if hardware supports it | Friendship established with Friend node | Low Power node |
| Friend | Hardware capable, currently off | Enabled + actively serving one or more LPNs | Friend node |
BlueZ: Querying and Configuring Features
# In mesh-cfgclient interactive shell:
$ mesh-cfgclient
[mesh-cfgclient]# connect /org/bluez/mesh
# Retrieve Composition Data from node 0x0001 (shows feature bitmask)
[mesh-cfgclient]# get-composition 0x0001 0
# Enable Relay feature (retransmit 2 times, 20ms interval)
[mesh-cfgclient]# set-relay 0x0001 enable 2 20
# Enable Proxy feature on a node
[mesh-cfgclient]# set-proxy 0x0001 enable
# Disable Friend feature (node stops storing messages for LPNs)
[mesh-cfgclient]# set-friend 0x0001 disable
/* Reading feature bits from Composition Data Page 0 in C.
* The Features field is a 16-bit little-endian value inside
* the Composition Data returned by Config Composition Data Status. */
#define FEAT_RELAY 0x0001
#define FEAT_PROXY 0x0002
#define FEAT_FRIEND 0x0004
#define FEAT_LOW_POWER 0x0008
static void parse_features(const uint8_t *comp_data, uint16_t len)
{
/* Features field starts at byte offset 10 in Page 0 */
uint16_t features = comp_data[10] | (comp_data[11] << 8);
if (features & FEAT_RELAY) printf(" Relay : supported\n");
if (features & FEAT_PROXY) printf(" Proxy : supported\n");
if (features & FEAT_FRIEND) printf(" Friend : supported\n");
if (features & FEAT_LOW_POWER) printf(" Low Power : supported\n");
}
Nodes with different features can be combined into virtually any layout — the spec places no topology restrictions. In practice, most deployments follow a natural pattern: a backbone of Relay nodes covering a large area, with clusters of Low Power Nodes sleeping near their Friend nodes, and Proxy nodes acting as GATT access points for phone apps.
Relay node
Friend node
Low Power node
Regular node
GATT-only node
Friend feat. not active
Example Mesh Topology:
S-GATT-T –>
|
A
|
D
|
L
LPN
|
||||
|
B
|
↘ |
Q
Relay
|
↔ |
N
Friend feat.
(no friends) |
↔ |
O
Friend
|
| ↑ | ↓ | ↓ | ↑ | ↓ | ||
|
C
|
P
Friend
|
↔ |
R
Relay
|
H
|
M
LPN (O’s)
|
|
| ↑↑↑ | ↓ | ↓ | ||||
|
I
J
K
LPNs (P’s)
|
↑↑↑ |
S
Relay + Proxy
|
← GATT → |
T
GATT only
|
G
|
Reading This Topology
- Q, R, S — the three Relay nodes form the network backbone, extending radio coverage hop by hop across the deployment area
- N — supports the Friend feature in hardware but currently has no active friendships, so it is not yet acting as a Friend node
- P — an active Friend node serving LPNs I, J, and K; all three are within one hop of P on the same subnet
- O — another active Friend node, serving LPNs L and M
- T — a GATT-only device (e.g., a smartphone running a mesh provisioner app); it has no native mesh advertising capability and relies entirely on Proxy node S to bridge its traffic into the mesh
Message Path: T → L (GATT to Low Power Node)
Here is how a message crosses three bearer types and three node roles to reach a sleeping Low Power Node:
| Step | From | To | Bearer | What Happens |
|---|---|---|---|---|
| 1 | T | S (Proxy) | GATT | T sends the message to its connected Proxy node S over a BLE GATT connection |
| 2 | S | H, R, N, O (broadcast) | ADV Bearer | S re-transmits the message as a standard mesh advertisement; all nodes within radio range receive it |
| 3 | Mesh flood | O (Friend of L) | ADV Bearer | O recognises the destination address belongs to LPN L and saves the message in its Friend Queue. If the message was segmented, O sends a lower-transport acknowledgement. |
| 4 | L (wakes) | O | Friend Poll | L wakes at its poll interval, sends a Friend Poll; O delivers the buffered message from T. L goes back to sleep. |
BlueZ: Provisioning and Topology Setup
# 1. Start the mesh daemon
sudo bluetooth-meshd --nodetach -l debug
# 2. Open the configuration client
$ mesh-cfgclient
# 3. Discover unprovisioned devices nearby
[mesh-cfgclient]# discover-unprovisioned on
# 4. Provision a node found during discovery (UUID from scan output)
[mesh-cfgclient]# provision <UUID>
# 5. After provisioning: assign address and enable features
[mesh-cfgclient]# set-relay 0x0005 enable 2 20 # Relay: 2 retransmits, 20ms gap
[mesh-cfgclient]# set-proxy 0x0005 enable # Enable Proxy on this node
[mesh-cfgclient]# set-friend 0x0006 enable # Enable Friend on a different node
# 6. Subscribe a model to a group address (builds the publish/subscribe mesh)
[mesh-cfgclient]# add-subscription 0x0005 0x0001 0xc000
Bluetooth Mesh deliberately uses different byte orderings at different layers. Getting this wrong is one of the most common sources of parsing bugs when implementing a mesh stack from scratch.
| Layer | Byte Order | Common Fields |
|---|---|---|
| Network Layer | Big Endian | IVI, NID, CTL, TTL, SEQ (24-bit), SRC, DST |
| Lower Transport Layer | Big Endian | SEG, AKF, AID, SZMIC, SeqZero, SegO, SegN |
| Upper Transport Layer | Big Endian | TransMIC, encrypted payload |
| Mesh Beacons & Provisioning | Big Endian | Network ID, IV Index in beacon |
| Access Layer | Little Endian | Model opcodes, state values (e.g. OnOff, Level) |
| Foundation Models | Little Endian | Config Model messages, Health Model counters |
Big Endian Field Packing (Network Layer)
When packing multiple bitfields into a Network PDU, start at the most significant bit of the first byte and work downward. Fields are listed top-to-bottom in spec tables and appear left-to-right on the wire:
| Field 0 4 bits [31:28] |
Field 1 12 bits [27:16] |
Field 2 16 bits [15:0] |
||||||
| Octet 0 upper nibble | Octet 0 lower nibble + Octet 1 | Octet 2 + Octet 3 (MSByte first) | ||||||
BlueZ: Endian Helper Patterns
/*
* BlueZ mesh stack uses explicit big/little endian helpers.
* Found in lib/bluetooth.h and mesh/net.c.
*/
/* Read a 16-bit big-endian value from a PDU buffer (network layer) */
static inline uint16_t get_be16(const uint8_t *ptr)
{
return ((uint16_t) ptr[0] << 8) | ptr[1];
}
/* Write a 16-bit big-endian value into a PDU buffer */
static inline void put_be16(uint16_t val, uint8_t *ptr)
{
ptr[0] = (uint8_t)(val >> 8);
ptr[1] = (uint8_t)(val);
}
/* Read a 16-bit little-endian value (access layer / model data) */
static inline uint16_t get_le16(const uint8_t *ptr)
{
return ((uint16_t) ptr[1] << 8) | ptr[0];
}
/* Write a 16-bit little-endian value */
static inline void put_le16(uint16_t val, uint8_t *ptr)
{
ptr[0] = (uint8_t)(val);
ptr[1] = (uint8_t)(val >> 8);
}
/* 24-bit SEQ number (big endian, 3 bytes) in Network PDU */
static inline uint32_t get_be24(const uint8_t *ptr)
{
return ((uint32_t) ptr[0] << 16) |
((uint32_t) ptr[1] << 8) |
(uint32_t) ptr[2];
}
Mesh Gateway
A mesh gateway is a node that translates traffic between the Bluetooth Mesh network and a non-Bluetooth technology — for example, Wi-Fi, Ethernet, Thread, or Zigbee. This lets cloud dashboards or building management systems communicate with mesh nodes even when the back-end infrastructure has no native BLE capability. The exact translation protocol is outside the scope of the Bluetooth Mesh specification.
|
🌐
Cloud / Non-BT Network
Wi-Fi · Ethernet · Thread · Zigbee
|
↔ |
🔀
Mesh Gateway Node
Protocol translation
(implementation defined) |
↔ |
📡
Bluetooth Mesh Network
ADV bearer · GATT bearer
|
Quick Reference Table
| Concept | Purpose | Key Numbers / Rules |
|---|---|---|
| IV Index | Extends 24-bit SEQ nonce lifetime | 32-bit · min 8-day update · max 24 Hz during update |
| Friendship | Power-saves battery nodes | 1 Friend per LPN · N LPNs per Friend · single hop · same subnet |
| Relay | Extends range hop by hop | Can be enabled / disabled · no hard hop count limit in spec |
| Proxy | Bridges GATT devices into mesh | GATT bearer ↔ ADV bearer · required for smartphone access |
| Network Endian | Consistent PDU parsing | Big endian: network/transport/beacons · Little endian: access/models |
| Topology | No restrictions imposed by spec | No node count limit · no hop limit · no concurrency limit |
Continue the Bluetooth Mesh Series
Next topics: Network PDU format, Transport layer segmentation, and Provisioning over ADV / GATT bearers.
