What is Mesh Security?
Bluetooth Mesh is used in smart lighting, industrial automation, and building control — places where attackers can physically be near the devices. So the Bluetooth SIG built a strong, layered security model directly into the Mesh Profile specification.
Every mesh message is encrypted (nobody can read it) and authenticated (nobody can fake or tamper with it). All of this is done using standard AES-128 building blocks that you already find in the BLE controller hardware.
Endianness note: All multi-byte numbers in the mesh security layer are transmitted in big-endian order (most significant byte first).
Keywords
The Security Toolbox — 8 Functions
Think of these 8 functions as a toolkit. Some encrypt data, some generate authentication tags, and some produce secret keys from a network key. They all build on top of AES-128, the same block cipher used in WPA2 Wi-Fi.
| Function | Purpose | Input | Output |
|---|---|---|---|
| e() | Basic AES-128 block encryption | 128-bit key + 128-bit plaintext | 128-bit ciphertext |
| AES-CMAC | Generate a message authentication tag | 128-bit key + variable data | 128-bit MAC tag |
| AES-CCM | Encrypt + authenticate in one shot | key + nonce + plaintext + AAD | ciphertext + MIC |
| s1() | Generate a SALT value from a string | ASCII string or byte array | 128-bit SALT |
| k1() | Derive IdentityKey / BeaconKey | N + SALT + P | 128-bit key |
| k2() | Derive EncryptionKey + PrivacyKey + NID | 128-bit N + P (≥1 byte) | 263-bit material |
| k3() | Derive public 64-bit value from private key | 128-bit N | 64-bit value |
| k4() | Derive public 6-bit value (AID) from AppKey | 128-bit N | 6-bit value |
This is the most basic building block — a raw AES-128 block cipher. Give it a 128-bit key and 128-bit plaintext, get back 128-bit ciphertext. Every other function in the toolbox ultimately calls this.
Block
Cipher
In BlueZ, the HCI layer exposes this via HCI_LE_Encrypt. The host can also call software AES. Below is how BlueZ invokes it through the management socket:
/* BlueZ tools/mesh/crypto.c — simplified */
bool aes_encrypt(const uint8_t key[16], const uint8_t in[16],
uint8_t out[16])
{
AES_KEY aes_key;
AES_set_encrypt_key(key, 128, &aes_key);
AES_encrypt(in, out, &aes_key); /* single AES-128 block */
return true;
}
CMAC (Cipher-based Message Authentication Code) answers the question: “Did this message really come from someone who knows the key, and was it modified in transit?”
You feed in a 128-bit key and any-length data. Out comes a 128-bit MAC tag. If even one bit of the data changes, the MAC tag looks completely different.
| k (128-bit key) | → | AES-CMAC Engine |
→ | MAC (128-bit authentication tag) |
| m (variable data) | → |
/* BlueZ tools/mesh/crypto.c */
bool aes_cmac(const uint8_t key[16], const uint8_t *msg,
size_t msg_len, uint8_t res[16])
{
return bt_crypto_aes_cmac(key, msg, msg_len, res);
}
CCM stands for Counter with CBC-MAC. It is the workhorse of mesh security — every mesh payload is protected by CCM. It solves two problems at once:
- Confidentiality — CTR mode encrypts the payload so eavesdroppers see only garbage.
- Integrity + Authenticity — CBC-MAC produces a MIC tag so the receiver knows the message was not tampered with.
| k — 128-bit key | → | AES-CCM k(n, m, a) |
→ | ciphertext |
| n — 104-bit nonce | MIC (integrity tag) | |||
| m — plaintext (variable) | ||||
| a — Additional Data (AAD) |
The nonce (n) is 104 bits and must be unique for every message under the same key. In mesh, the nonce is constructed from the sequence number (SEQ), source address (SRC), and IV index. This guarantees uniqueness.
The AAD (a) is authenticated but not encrypted — typically used for mesh header fields that the relay nodes need to read.
/* BlueZ tools/mesh/crypto.c — mesh upper transport encryption */
bool mesh_crypto_payload_encrypt(const uint8_t *aad, uint8_t aad_len,
const uint8_t *payload, uint8_t payload_len,
const uint8_t key[16],
const uint8_t nonce[13], /* 104 bits */
uint8_t *out, uint8_t *out_mic,
uint8_t mic_size)
{
/* aes_ccm_encrypt wraps OpenSSL EVP_CIPHER_CTX with AES-128-CCM */
return aes_ccm_encrypt(key, nonce, 13,
aad, aad_len,
payload, payload_len,
out, out_mic, mic_size);
}
Before deriving any key, you need a SALT — a domain separator that prevents key reuse between different contexts. The spec derives SALTs from short ASCII strings like "smk2", "smk3", "smk4".
Internally, s1 is just AES-CMAC with the all-zero 128-bit key:
ASCII → bytes
(key = 0x00…00)
/* BlueZ tools/mesh/crypto.c */
bool mesh_crypto_s1(const void *m, size_t m_len, uint8_t salt[16])
{
/* Key is all zeros for s1 */
static const uint8_t zero_key[16] = { 0 };
return bt_crypto_aes_cmac(zero_key, m, m_len, salt);
}
/* Example: generate SALT for k2 */
uint8_t salt[16];
mesh_crypto_s1("smk2", 4, salt);
k1 is a two-step CMAC chain. It derives keys like IdentityKey (used in proxy advertisements) and BeaconKey (used in secure network beacons).
| Step | Operation | Result |
|---|---|---|
| 1 | T = AES-CMACSALT(N) | Intermediate key T (128-bit) |
| 2 | k1(N,SALT,P) = AES-CMACT(P) | Final derived key (128-bit) |
/* BlueZ tools/mesh/crypto.c */
bool mesh_crypto_k1(const uint8_t *n, uint8_t n_len,
const uint8_t salt[16],
const void *p, uint8_t p_len,
uint8_t res[16])
{
uint8_t t[16];
/* Step 1: T = AES-CMAC_SALT(N) */
if (!bt_crypto_aes_cmac(salt, n, n_len, t))
return false;
/* Step 2: output = AES-CMAC_T(P) */
return bt_crypto_aes_cmac(t, p, p_len, res);
}
k2 is the most important derivation function in mesh. From a single 128-bit Network Key (NetKey), it generates three separate values that are used to secure every network-layer message:
- NID (7 bits) — a short public identifier that tells receivers which key was used
- EncryptionKey (128 bits) — encrypts the network PDU payload
- PrivacyKey (128 bits) — obfuscates the network header (SRC, SEQ, etc.)
| Step | Operation | Output |
|---|---|---|
| 0 | SALT = s1(“smk2”) | 128-bit SALT |
| 1 | T = AES-CMACSALT(N) | Intermediate key T |
| 2 | T1 = AES-CMACT(T0 ‖ P ‖ 0x01) | 128 bits (feeds NID + first part of EncKey) |
| 3 | T2 = AES-CMACT(T1 ‖ P ‖ 0x02) | 128 bits (rest of EncryptionKey) |
| 4 | T3 = AES-CMACT(T2 ‖ P ‖ 0x03) | 128 bits (PrivacyKey) |
| ✓ | (T1 ‖ T2 ‖ T3) mod 2²⁶³ | NID[7b] + EncryptionKey[128b] + PrivacyKey[128b] |
/* BlueZ tools/mesh/crypto.c */
bool mesh_crypto_k2(const uint8_t n[16], const uint8_t *p, uint8_t p_len,
uint8_t *nid, uint8_t enc_key[16], uint8_t priv_key[16])
{
uint8_t salt[16], t[16], t1[16], t2[16], t3[16];
/* SALT = s1("smk2") */
mesh_crypto_s1("smk2", 4, salt);
/* T = AES-CMAC_SALT(N) */
bt_crypto_aes_cmac(salt, n, 16, t);
/* T1, T2, T3 chained CMAC */
uint8_t buf[48];
/* T1: empty || P || 0x01 */
memcpy(buf, p, p_len); buf[p_len] = 0x01;
bt_crypto_aes_cmac(t, buf, p_len + 1, t1);
/* T2: T1 || P || 0x02 */
memcpy(buf, t1, 16); memcpy(buf+16, p, p_len); buf[16+p_len] = 0x02;
bt_crypto_aes_cmac(t, buf, 16 + p_len + 1, t2);
/* T3: T2 || P || 0x03 */
memcpy(buf, t2, 16); memcpy(buf+16, p, p_len); buf[16+p_len] = 0x03;
bt_crypto_aes_cmac(t, buf, 16 + p_len + 1, t3);
/* Extract: LSB of T1 = NID (7 bits), T2 = EncKey, T3 = PrivKey */
*nid = t1[15] & 0x7f;
memcpy(enc_key, t2, 16);
memcpy(priv_key, t3, 16);
return true;
}
k3 takes a private 128-bit key (like a Network Key) and produces a 64-bit public fingerprint. This is used in the Network Key identifier so that nodes can advertise “I know key X” without revealing the actual key.
| Step | Operation |
|---|---|
| 1 | SALT = s1(“smk3”) |
| 2 | T = AES-CMACSALT(N) |
| 3 | k3(N) = AES-CMACT(“id64” ‖ 0x01) mod 2⁶⁴ |
/* BlueZ tools/mesh/crypto.c */
bool mesh_crypto_k3(const uint8_t n[16], uint8_t res[8])
{
uint8_t salt[16], t[16], full[16];
uint8_t p[] = { 'i','d','6','4', 0x01 }; /* "id64" || 0x01 */
mesh_crypto_s1("smk3", 4, salt);
bt_crypto_aes_cmac(salt, n, 16, t);
bt_crypto_aes_cmac(t, p, sizeof(p), full);
/* Keep only the last 8 bytes (64 bits) */
memcpy(res, full + 8, 8);
return true;
}
k4 is the smallest derivation — it produces only 6 bits from a 128-bit Application Key. This 6-bit value is called the AID (Application Key Identifier) and is included in the Lower Transport PDU header so that a receiving node can quickly pick the right AppKey to decrypt a message without trying all keys.
| Step | Operation |
|---|---|
| 1 | SALT = s1(“smk4”) |
| 2 | T = AES-CMACSALT(N) |
| 3 | k4(N) = AES-CMACT(“id6” ‖ 0x01) mod 2⁶ |
/* BlueZ tools/mesh/crypto.c */
bool mesh_crypto_k4(const uint8_t n[16], uint8_t *aid)
{
uint8_t salt[16], t[16], full[16];
uint8_t p[] = { 'i','d','6', 0x01 }; /* "id6" || 0x01 */
mesh_crypto_s1("smk4", 4, salt);
bt_crypto_aes_cmac(salt, n, 16, t);
bt_crypto_aes_cmac(t, p, sizeof(p), full);
/* Keep only the lowest 6 bits of the last byte */
*aid = full[15] & 0x3f;
return true;
}
How It All Fits Together
Here is the big picture — from the raw Network Key to a protected over-the-air mesh PDU:
| Network Key (128-bit, stored securely on device) | ||||||
| ↓ | ↓ | ↓ | ↓ | |||
| k2() NID |
k2() EncryptionKey |
k2() PrivacyKey |
k3() Net ID |
|||
| ↓ | ↓ | ↓ | ↓ | |||
| Header tag in PDU | AES-CCM payload encryption | Obfuscate SRC & SEQ | Proxy & Beacon advertisements | |||
| Function | SALT string | Output size | Used for |
|---|---|---|---|
| e() | — | 128 bits | Raw AES block |
| AES-CMAC | — | 128 bits | Authentication tag |
| AES-CCM | — | ciphertext + MIC | PDU encryption |
| s1() | any string | 128 bits | SALT for k2/k3/k4 |
| k1() | caller-provided | 128 bits | IdentityKey, BeaconKey |
| k2() | “smk2” | 263 bits | NID + EncKey + PrivKey |
| k3() | “smk3” | 64 bits | Network ID (public) |
| k4() | “smk4” | 6 bits | AID (AppKey identifier) |
Next Up: Mesh Network PDU & Obfuscation
Now that you know how keys are derived and how CCM protects the payload, the next post covers how the network PDU header is assembled and how PrivacyKey obfuscates the SRC and SEQ fields.
