Why does Mesh need special security?
In a Bluetooth Mesh network, every message hops from node to node — like a chain of people passing a note. An attacker sitting in that chain could re-send an old note to trick the network (replay attack). Bluetooth Mesh blocks this with three interlocking mechanisms: a Sequence Number that ticks upward on every message, an IV Index that resets the counter before it overflows, and a Nonce that combines both values so that every encryption operation is completely unique.
This post walks through each mechanism step by step, shows the exact byte layout used on air, and demonstrates how BlueZ / the Linux kernel exposes these values.
Keywords covered
Every time an element inside a mesh node sends a message, it stamps that message with a Sequence Number (SEQ) — a simple 24-bit integer stored in the SEQ field of the Network PDU. The rule is strict: each new outgoing message must use a number that is higher than the last one.
Why strictly increasing?
When a receiving node gets a message, it checks the SEQ against the last SEQ it saw from that source address. If the new SEQ is equal to or lower than the stored one, the message is dropped immediately — it is either a duplicate or a replayed packet.
| Scenario | Incoming SEQ | Stored SEQ | Decision |
|---|---|---|---|
| Normal fresh message | 1005 | 1004 | ✓ Accept |
| Replayed old message | 1003 | 1004 | ✗ Drop |
| Duplicate in-flight | 1004 | 1004 | ✗ Drop |
How large is the counter space?
A 24-bit number can reach a maximum of 16,777,216. If an element sends one message every 5 seconds (a fairly busy device), it takes about 2.6 years to exhaust the counter. Before that happens, the node must run the IV Update procedure to reset the counter safely.
BlueZ: reading the sequence number
BlueZ stores the current sequence number per element in /var/lib/bluetooth/<adapter>/mesh/<node-token>/config. You can also watch it in meshctl debug output:
# Start meshctl with debug logging
sudo meshctl --dbus-debug
# Inside meshctl, send a message and watch the log
[meshctl]# send 0100 0a
# BlueZ debug output will show:
# mesh_net.c: Network SEQ sent: 0x00002A
# mesh_net.c: Nonce built with SEQ=0x00002A IV=0x00000001
The source code in BlueZ that manages the sequence number lives in mesh/net.c:
/* mesh/net.c (simplified) */
/* get next sequence number for an element */
static uint32_t get_next_seq(struct mesh_net *net, uint16_t src)
{
uint32_t seq = net->seq_num;
net->seq_num++; /* strictly incrementing */
/* flush to storage so it survives a reboot */
storage_save_sequence(net, src, net->seq_num);
return seq;
}
The IV Index is like a “chapter number” for the whole mesh network. While the SEQ is a “page number” within a chapter, the IV Index says which chapter we are in. All nodes in the same mesh network share the exact same IV Index value at any given time.
Starting value and increment rule
The IV Index starts at 0x00000000. It is incremented by 1 every time any node’s sequence number gets close to its maximum (0xFFFFFF). This is called the IV Update procedure. Because IV Index is 32 bits wide, the entire mesh network can run for approximately 5 trillion years before the IV Index wraps — effectively forever.
| Concept | Book analogy | Bit width | Scope | Who controls it? |
|---|---|---|---|---|
| SEQ | Page number | 24 bit | Per element | Each element independently |
| IV Index | Chapter number | 32 bit | Whole network | Any node via IV Update |
| SEQ + IV Index | Chapter + Page | 56 bit combined | Unique per message | Together form the Nonce input |
How is the IV Index shared?
Every node broadcasts Secure Network Beacons periodically. These beacons carry the current IV Index value (and a 1-bit flag showing whether an IV Update is in progress). Any node that has been powered off for a while can listen for these beacons and catch up immediately.
Subnet rule for IV Index propagation
A node connected to the primary subnet that receives an IV update on the primary subnet must propagate it to all other subnets it belongs to. However, if that same node receives an IV update on a secondary subnet, it must ignore it — only primary-subnet updates are authoritative.
BlueZ: reading the IV Index
# BlueZ stores IV Index in the node config JSON
cat /var/lib/bluetooth/<BD_ADDR>/mesh/<token>/config | python3 -m json.tool | grep -i iv
# Expected output:
# "IVindex": 1,
# "IVupdate": 0
/* mesh/net.c — IV Index access (simplified) */
uint32_t mesh_net_get_iv_index(struct mesh_net *net)
{
return net->iv_index;
}
/* called when a Secure Network Beacon arrives with a higher IV */
static void process_iv_update(struct mesh_net *net, uint32_t new_iv)
{
if (new_iv > net->iv_index) {
net->iv_index = new_iv;
net->seq_num = 0; /* SEQ resets after IV increment */
storage_save_iv(net);
}
}
The word nonce literally means “number used once”. In Bluetooth Mesh, the nonce is a 13-byte value fed into the AES-CCM encryption engine alongside the actual message payload and a key. If the nonce is ever the same for two different messages encrypted with the same key, the encryption is broken — an attacker can recover the plaintext. The entire SEQ + IV Index design exists purely to guarantee the nonce is always unique.
| AES-CCM Inputs → Encrypted PDU | ||
|---|---|---|
|
Encryption Key
Network Key or App Key
(128-bit) |
Nonce
SEQ + IV Index
+ address fields (13 bytes, always unique) |
Plaintext
Actual message data
|
| ↓ AES-CCM ↓ | ||
|
Encrypted PDU + MIC (Message Integrity Check)
Safe to transmit over air
|
||
The four nonce types at a glance
| Type byte | Name | Used with | Includes TTL? | Includes DST? | Purpose |
|---|---|---|---|---|---|
| 0x00 | Network nonce | Network Key | ✓ Yes | ✗ No | Encrypt/auth at network layer |
| 0x01 | Application nonce | App Key | ✗ No | ✓ Yes | Encrypt/auth upper transport (app data) |
| 0x02 | Device nonce | Device Key | ✗ No | ✓ Yes | Provisioning / config messages |
| 0x03 | Proxy nonce | Network Key | ✗ No | ✗ No | GATT Proxy connections |
Notice the key design decisions baked into the table above: the TTL (Time To Live) is inside the Network nonce but not inside the Application or Device nonce. This is intentional — when a relay decrements the TTL, the network-layer nonce changes (so the relay re-authenticates the TTL), but the application-layer nonce stays the same (the app doesn’t care how many hops the message took). Similarly, the DST (destination address) is encrypted at the network layer — it goes into the application/device nonce but not the network nonce.
The Network nonce is exactly 13 bytes wide. Let’s walk through each field:
| Octet 0 | Octet 1 | Octet 2 | Octet 3 | Octet 4 | Octet 5 | Octet 6 | Octet 7 | Octet 8 | Octet 9 | Octet 10 | Octet 11 | Octet 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0x00 Type |
CTL|TTL 1B |
SEQ byte 2 |
SEQ byte 1 |
SEQ byte 0 |
SRC hi |
SRC lo |
0x00 Pad |
0x00 Pad |
IVI byte 3 |
IVI byte 2 |
IVI byte 1 |
IVI byte 0 |
| 1 byte | 1 byte | 3 bytes (SEQ) | 2 bytes (SRC) | 2 bytes (Pad) | 4 bytes (IV Index) | |||||||
What is the CTL|TTL byte?
Octet 1 packs two fields into one byte: the most-significant bit is CTL (0 = access message, 1 = control message) and the remaining 7 bits hold the TTL (Time To Live, 0–127). This combined byte is included in the nonce so that relays cannot silently change the TTL without breaking authentication.
| Bit 7 (MSB) | Bit 6 | Bit 5 | Bit 4 | Bit 3 | Bit 2 | Bit 1 | Bit 0 (LSB) |
|---|---|---|---|---|---|---|---|
| CTL | TTL [6:0] | ||||||
| 0=access 1=control |
0–127 hops remaining | ||||||
BlueZ: building the Network nonce
/* mesh/crypto.c — network nonce construction (simplified) */
void mesh_crypto_network_nonce(uint8_t ctl,
uint8_t ttl,
uint32_t seq,
uint16_t src,
uint32_t iv_index,
uint8_t nonce[13])
{
nonce[0] = 0x00; /* Nonce Type: Network */
nonce[1] = (ctl << 7) | (ttl & 0x7F); /* CTL | TTL */
nonce[2] = (seq >> 16) & 0xFF; /* SEQ MSB */
nonce[3] = (seq >> 8) & 0xFF;
nonce[4] = seq & 0xFF; /* SEQ LSB */
nonce[5] = (src >> 8) & 0xFF; /* SRC hi */
nonce[6] = src & 0xFF; /* SRC lo */
nonce[7] = 0x00; /* Pad */
nonce[8] = 0x00; /* Pad */
nonce[9] = (iv_index >> 24) & 0xFF; /* IV MSB */
nonce[10] = (iv_index >> 16) & 0xFF;
nonce[11] = (iv_index >> 8) & 0xFF;
nonce[12] = iv_index & 0xFF; /* IV LSB */
}
/* Example call: element 0x0101, seq=0x2A, iv=1, TTL=5, access msg */
uint8_t nonce[13];
mesh_crypto_network_nonce(0, 5, 0x2A, 0x0101, 1, nonce);
/* nonce = { 00, 05, 00, 00, 2A, 01, 01, 00, 00, 00, 00, 00, 01 } */
The Application nonce and Device nonce share an almost identical structure. The key difference from the Network nonce is that they include the destination address (DST) and drop the TTL. They are used one layer higher — at the upper transport layer — with the Application Key or Device Key respectively.
| Oct 0 | Oct 1 | Oct 2 | Oct 3 | Oct 4 | Oct 5 | Oct 6 | Oct 7 | Oct 8 | Oct 9 | Oct 10 | Oct 11 | Oct 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0x01 App |
ASZMIC |Pad |
SEQ b2 |
SEQ b1 |
SEQ b0 |
SRC hi |
SRC lo |
DST hi |
DST lo |
IVI b3 |
IVI b2 |
IVI b1 |
IVI b0 |
What is ASZMIC?
ASZMIC stands for Access Segmented Message Integrity Check. It occupies bit 7 of octet 1. For a segmented access message (one that had to be split into multiple PDUs), ASZMIC mirrors the SZMIC flag which controls whether the MIC is 4 bytes or 8 bytes long. For all unsegmented messages it is always 0.
| Bit 7 (MSB) | Bit 6 | Bit 5 | Bit 4 | Bit 3 | Bit 2 | Bit 1 | Bit 0 (LSB) |
|---|---|---|---|---|---|---|---|
| ASZMIC | Pad = 0b0000000 | ||||||
| 1=8-byte MIC 0=4-byte MIC |
always zeros | ||||||
BlueZ: building the Application nonce
/* mesh/crypto.c — application nonce construction (simplified) */
void mesh_crypto_application_nonce(uint8_t aszmic,
uint32_t seq,
uint16_t src,
uint16_t dst,
uint32_t iv_index,
uint8_t nonce[13])
{
nonce[0] = 0x01; /* Nonce Type: Application */
nonce[1] = (aszmic & 0x01) << 7; /* ASZMIC | Pad */
nonce[2] = (seq >> 16) & 0xFF;
nonce[3] = (seq >> 8) & 0xFF;
nonce[4] = seq & 0xFF;
nonce[5] = (src >> 8) & 0xFF;
nonce[6] = src & 0xFF;
nonce[7] = (dst >> 8) & 0xFF; /* DST hi — not in net nonce */
nonce[8] = dst & 0xFF; /* DST lo */
nonce[9] = (iv_index >> 24) & 0xFF;
nonce[10] = (iv_index >> 16) & 0xFF;
nonce[11] = (iv_index >> 8) & 0xFF;
nonce[12] = iv_index & 0xFF;
}
/* Device nonce is exactly the same but nonce[0] = 0x02 */
Bluetooth Mesh uses two independent layers of encryption on every access message. Understanding which nonce is used at each layer is the key to understanding the whole security model.
| Sender (Source Element) | ||||
|
Step 1 — Upper Transport
Encrypt payload with App Key using Application Nonce (0x01)
AES-CCM(AppKey, AppNonce, Payload) → EncPayload + MIC4
|
→ |
Step 2 — Network Layer
Encrypt (EncPayload + DST) with Net Key using Network Nonce (0x00)
AES-CCM(NetKey, NetNonce, EncPayload+DST) → NetworkPDU
|
→ |
Step 3 — Air
NetworkPDU transmitted over BLE advertisements
|
|
Relay node — only touches Network layer
Decrypts with NetKey, decrements TTL (network nonce changes), re-encrypts, forwards. Cannot read the payload — no App Key.
|
||||
| Receiver (Destination Element) | ||||
|
Step A — Network decrypt
Decrypt with Net Key + Network Nonce → recovers EncPayload + DST
|
→ |
Step B — Upper transport decrypt
Decrypt with App Key + Application Nonce → recovers Plaintext
|
→ |
Step C — Deliver
Model receives plaintext application message
|
Quick summary: which fields change at each hop?
| Event | SEQ | TTL | Network Nonce | App/Device Nonce |
|---|---|---|---|---|
| Original send | Set once | Set once | Built | Built |
| Relay hop | Unchanged | Decremented | Rebuilt (TTL changed) | Unchanged |
| IV Update fires | Resets to 0 | N/A | IV part changes | IV part changes |
| Question | Answer |
|---|---|
| What does SEQ protect against? | Replay attacks — old messages are rejected if SEQ ≤ last seen |
| How many messages can one element send before needing IV Update? | 16,777,216 (2²⁴) |
| How is IV Index shared across the network? | Via Secure Network Beacons broadcast by every node |
| What is a Nonce and how big is it? | A unique 13-byte value fed into AES-CCM; never reused |
| Why does the Network Nonce include TTL but not Application Nonce? | So relay nodes can authenticate the TTL without seeing the payload |
| Why does the Application Nonce include DST but not Network Nonce? | DST is encrypted at network layer; only the intended receiver can read it with App Key |
| What is ASZMIC? | 1-bit flag in app/device nonce; selects 4-byte (0) or 8-byte (1) MIC for segmented messages |
| Which BlueZ file handles crypto operations? | mesh/crypto.c |
Explore more on EmbeddedPathashala
Dive deeper into Bluetooth Mesh, BLE stack internals, and Linux kernel programming.
