Bluetooth Mesh Security Explained

Bluetooth Mesh Security Explained
AES-128 · CMAC · CCM · Salt & Key Derivation — in plain language
8
Security Functions
128-bit
AES Key Size
104-bit
Nonce in CCM

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

AES-128 AES-CMAC AES-CCM Nonce SALT k1 derivation k2 derivation k3 derivation k4 derivation NID EncryptionKey PrivacyKey BeaconKey IdentityKey MIC

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

1 · The Encryption Function e()

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.

Key (128-bit)
AES-128
Block
Cipher
Ciphertext (128-bit)
Plaintext (128-bit)

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;
}

2 · AES-CMAC — The Authentication Tag

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);
}
💡 Real-world use: The Mesh Network key, App key, and device key are all authenticated using AES-CMAC before a message leaves the node.

3 · AES-CCM — Encrypt AND Authenticate Together

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);
}

4 · s1() — SALT Generation Function

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:

M (e.g. “smk2”)
ASCII → bytes
AES-CMAC
(key = 0x00…00)
SALT (128-bit)
/* 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);

5 · k1() — IdentityKey and BeaconKey Derivation

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);
}

6 · k2() — EncryptionKey, PrivacyKey, and NID

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;
}

7 · k3() — Public 64-bit Value from a Private Key

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;
}

8 · k4() — AID: 6-bit Application Key Identifier

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

Quick Reference — All 8 Functions
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.

← Back to Mesh Series Home

Leave a Reply

Your email address will not be published. Required fields are marked *