Why Does Mesh Need Security?
In a Bluetooth Mesh network, messages hop from node to node across a large area — think smart lighting in a building, or industrial sensors on a factory floor. Any node in range can hear those messages. Without proper security, an attacker could replay old commands, inject fake ones, or read private data.
The Mesh Profile specification solves this using a layered security model: nonces prevent replay attacks, and keys provide encryption and authentication at multiple layers of the stack.
Key Concepts in This Post
A nonce (Number used ONCE) is a unique value that is combined with a key when encrypting a message. Even if the same message is sent twice with the same key, a different nonce produces a completely different ciphertext. This stops replay attacks — where an attacker records a valid message and sends it again later.
In Bluetooth Mesh, a nonce is always 13 bytes (octets) long. Each nonce type serves a different layer or purpose in the stack.
The proxy nonce is used to secure proxy configuration messages — these are control messages exchanged between a GATT Proxy node and a connected smartphone or provisioner. It uses an encryption key derived from the NetKey.
SEQ (Sequence Number) increments with every message sent from that node. IV Index (Initialization Vector Index) is a global counter that the whole mesh network shares and increments over time. Together, SEQ + SRC + IV Index make every nonce globally unique across the entire mesh.
/* Proxy nonce: used for proxy config message encryption */
#include <string.h>
void mesh_create_proxy_nonce(uint8_t *nonce,
uint32_t seq,
uint16_t src,
uint32_t iv_index)
{
/* Octet 0: Nonce Type = 0x03 (Proxy) */
nonce[0] = 0x03;
/* Octet 1: Pad = 0x00 */
nonce[1] = 0x00;
/* Octets 2–4: SEQ (big-endian, 3 bytes) */
nonce[2] = (seq >> 16) & 0xFF;
nonce[3] = (seq >> 8) & 0xFF;
nonce[4] = (seq ) & 0xFF;
/* Octets 5–6: SRC (big-endian, 2 bytes) */
nonce[5] = (src >> 8) & 0xFF;
nonce[6] = (src ) & 0xFF;
/* Octets 7–8: Pad = 0x0000 */
nonce[7] = 0x00;
nonce[8] = 0x00;
/* Octets 9–12: IV Index (big-endian, 4 bytes) */
nonce[9] = (iv_index >> 24) & 0xFF;
nonce[10] = (iv_index >> 16) & 0xFF;
nonce[11] = (iv_index >> 8) & 0xFF;
nonce[12] = (iv_index ) & 0xFF;
}
Bluetooth Mesh uses a two-layer security model. Messages are encrypted twice — once at the upper transport layer (using an AppKey or DevKey) and once at the network layer (using a NetKey). An attacker who breaks one layer still cannot read the full message.
| Key | Full Name | Used At | Known By | Unique Per |
|---|---|---|---|---|
| AppKey | Application Key | Upper Transport Layer | All nodes in same app group | Application (e.g. lighting) |
| NetKey | Network Key | Network Layer | All nodes in same subnet | Subnet |
| DevKey | Device Key | Access Layer (config messages) | Node + Configuration Client only | Each individual node |
Rule: Each AppKey is bound to exactly one NetKey. But one NetKey can have multiple AppKeys bound to it. A DevKey is implicitly bound to all NetKeys.
The DevKey is what makes each node individually addressable for configuration. When a provisioner (like a phone app) wants to configure a specific node — say, set its publish address — it uses that node’s unique DevKey to encrypt the config message. No other node can read or tamper with it.
DevKey is derived during the provisioning process, using two inputs:
ECDH key exchange
confirmation values
DevKey = k1(ECDHSecret, ProvisioningSalt, "prdk")The string
"prdk" is a domain separator — it makes this k1 call unique compared to other k1 uses./* In BlueZ, each provisioned node stores its DevKey.
mesh-config.json has a "deviceKey" field per node. */
struct mesh_node {
uint16_t unicast; /* Node's unicast address */
uint8_t dev_key[16]; /* 128-bit DevKey */
uint8_t num_ele; /* Number of elements */
/* ... */
};
/* Config messages (e.g., Config AppKey Add) are
encrypted with the node's DevKey, not AppKey.
This is why provisioner must track each DevKey. */
/* Deriving DevKey using k1 (AES-CMAC based): */
void derive_dev_key(const uint8_t *ecdh_secret,
const uint8_t *prov_salt,
uint8_t *dev_key)
{
const uint8_t label[] = "prdk";
/* k1(N, SALT, P):
T = AES-CMAC_SALT(N)
k1 = AES-CMAC_T(P) */
mesh_crypto_k1(ecdh_secret, 32, prov_salt, label, 4, dev_key);
}
An AppKey is randomly generated (using a cryptographically secure RNG) and manually distributed to all nodes that need to share an application. For example, all light switches and light bulbs in one room share the same AppKey.
But how does a receiving node quickly figure out which AppKey was used to encrypt an incoming message — without trying all of them? That’s where the AID (Application Key Identifier) comes in.
AID = k4(AppKey)The AID travels in plaintext inside the Access PDU header. A receiver checks the AID to pick the right AppKey for decryption — it’s like a hint, not a secret.
/* Receiver side: find matching AppKey by AID */
bool find_app_key_by_aid(struct mesh_node *node,
uint8_t aid,
uint8_t **app_key_out)
{
for (int i = 0; i < node->num_app_keys; i++) {
uint8_t candidate_aid;
/* k4: AES-CMAC of AppKey, take lower 6 bits */
mesh_crypto_k4(node->app_keys[i], &candidate_aid);
if ((candidate_aid & 0x3F) == (aid & 0x3F)) {
*app_key_out = node->app_keys[i];
return true;
}
}
return false; /* No matching key found */
}
The NetKey is the root of all network-level security material. From a single NetKey, the mesh stack derives four pieces of material using cryptographic key derivation functions (k1, k2, k3):
128-bit random key
/* Derive NID, EncryptionKey, PrivacyKey from NetKey.
k2(NetKey, 0x00) for master security credentials. */
struct net_key_material {
uint8_t nid; /* 7 bits */
uint8_t enc_key[16]; /* 128-bit encryption key */
uint8_t priv_key[16]; /* 128-bit privacy key */
};
void derive_net_key_material(const uint8_t *net_key,
struct net_key_material *out)
{
uint8_t p[1] = { 0x00 }; /* Master credentials */
uint8_t result[33]; /* NID(1) + EncKey(16) + PrivKey(16) */
/* k2 uses CMAC-based HKDF internally */
mesh_crypto_k2(net_key, p, sizeof(p), result);
out->nid = result[0] & 0x7F; /* Lower 7 bits */
memcpy(out->enc_key, result + 1, 16);
memcpy(out->priv_key, result + 17, 16);
}
When a mesh node sends an application message (e.g., “Turn light ON”), it goes through two rounds of encryption before hitting the air:
| Function | Input | Output | Used For |
|---|---|---|---|
k1 |
N, SALT, P | 128-bit key | DevKey, IdentityKey, BeaconKey |
k2 |
NetKey, P | NID + EncKey + PrivKey | Network PDU security material |
k3 |
NetKey | 64-bit Network ID | Public subnet identifier |
k4 |
AppKey | 6-bit AID | Application key identifier |
Continue the Bluetooth Mesh Series
Next: Provisioning Protocol — how a new node gets its DevKey, NetKey, and joins the mesh securely.
