Bluetooth Mesh tutorial : Network Layer Deep Dive

Bluetooth Mesh tutorial : Network Layer Deep Dive

Network PDU Fields · Interfaces · Relay & Proxy · Receive & Transmit Paths — with BlueZ Mesh Daemon Code

9
PDU Fields
4
Interface Types
2
Network Features
BlueZ
mesh/net.c

What This Tutorial Covers

The Bluetooth Mesh network layer is the engine that moves messages across the mesh without any central coordinator. Every node in a mesh network is simultaneously a consumer and a potential forwarder of traffic. Understanding how the network layer works is essential before you can debug relay loops, trace authentication failures, or implement custom profile behaviour in BlueZ.

This post covers the five remaining Network PDU fields (SEQ, SRC, DST, TransportPDU, NetMIC), the network interface abstraction, the Relay and Proxy features, and the complete receive and transmit pipelines. Every concept is anchored to actual BlueZ mesh daemon code from mesh/net.c and mesh/crypto.c.

Prerequisites: BLE advertising basics, provisioning fundamentals (what a node, element, and subnet are), and working C knowledge. You should already understand the IVI, NID, CTL, and TTL fields from the previous post in this series.

Key Concepts in This Post

Network PDU SEQ Field SRC Address DST Address TransportPDU NetMIC CTL Bit Network Interface Input / Output Filter Local Interface Advertising Bearer Relay Feature Proxy Feature Network Message Cache IV Index Replay Protection bluetooth-meshd mesh/net.c

Part 1 — Network PDU: The Complete Field Reference

A Bluetooth Mesh Network PDU is the fundamental unit of communication at the network layer. Before a higher-layer message reaches its destination, the network layer wraps it, encrypts the payload with the network Encryption Key, obfuscates the header with the Privacy Key, and broadcasts the result over the air. Every relay node along the path re-broadcasts this same PDU after decrementing the TTL.

Here is the full Network PDU field layout. The first two bytes (IVI+NID and CTL+TTL) share one byte each. Everything from CTL through SRC is obfuscated after encryption, hiding the source address and hop-count from passive observers:

IVI
1 bit
NID
7 bits
CTL
1 bit
TTL
7 bits
SEQ
24 bits
SRC
16 bits
DST
16 bits
TransportPDU
up to 128 bits (CTL=0) / 96 bits (CTL=1)
NetMIC
32 bits (CTL=0) / 64 bits (CTL=1)
Byte 0 — not obfuscated Bytes 1–6 — obfuscated with Privacy Key Bytes 7+ — encrypted with Encryption Key — NetMIC appended

The encryption and obfuscation mean that a node must hold the correct network key before it can read anything beyond the NID. This is what keeps mesh traffic confidential even though it is broadcast openly over the air.

In BlueZ’s mesh daemon, there is no single packed C struct for the full Network PDU because obfuscation scrambles the byte layout. Instead, BlueZ extracts each field individually after the privacy layer has been stripped. Here is how that extraction looks in mesh/net.c:

/*
 * mesh/net.c
 *
 * Raw field extraction from a Network PDU buffer.
 * Called after mesh_crypto_privacy_decrypt() has de-obfuscated
 * bytes 1-6, so the header is now in cleartext.
 *
 * pkt[]  - raw Network PDU as received from the bearer
 * len    - total PDU length in bytes
 */
static bool net_pkt_parse_fields(const uint8_t *pkt, uint8_t len,
                                 uint8_t  *out_ivi,
                                 uint8_t  *out_nid,
                                 uint8_t  *out_ctl,
                                 uint8_t  *out_ttl,
                                 uint32_t *out_seq,
                                 uint16_t *out_src,
                                 uint16_t *out_dst,
                                 uint8_t  *out_mic_len)
{
    if (len < 9)   /* minimum viable Network PDU */
        return false;

    /* Byte 0: IVI (MSB) | NID (lower 7 bits) — never obfuscated */
    *out_ivi = (pkt[0] >> 7) & 0x01;
    *out_nid = pkt[0] & 0x7f;

    /*
     * After de-obfuscation, bytes 1-6 are readable:
     *
     * Byte 1: CTL (bit 7) | TTL (bits 6-0)
     * Bytes 2-4: SEQ — 24-bit big-endian sequence number
     * Bytes 5-6: SRC — 16-bit big-endian source unicast address
     */
    *out_ctl = (pkt[1] >> 7) & 0x01;
    *out_ttl = pkt[1] & 0x7f;

    *out_seq = ((uint32_t)pkt[2] << 16) |
               ((uint32_t)pkt[3] <<  8) |
               ((uint32_t)pkt[4]);

    *out_src = ((uint16_t)pkt[5] << 8) | pkt[6];

    /*
     * Bytes 7-8: DST — still encrypted; decrypted after AES-CCM
     * passes. We only read it here post-decryption.
     */
    *out_dst = ((uint16_t)pkt[7] << 8) | pkt[8];

    /* NetMIC is 4 bytes when CTL=0, 8 bytes when CTL=1 */
    *out_mic_len = (*out_ctl) ? 8 : 4;

    return true;
}

3.4.4.5 — SEQ: The Sequence Number Field

SEQ is a 24-bit monotonically increasing integer. Every element increments it by exactly 1 each time it originates a new Network PDU. The Bluetooth Mesh spec requires that the triplet of (SEQ, IV Index, SRC) is globally unique for every PDU ever transmitted by a given element for as long as the mesh network exists.

That uniqueness guarantee is the foundation of replay attack prevention. If an attacker captures a valid PDU and retransmits it later, the receiving nodes will have already seen and cached that (SRC, SEQ, IV Index) combination and will silently discard the replay.

Why 24 Bits?

At 24 bits, a single element can send up to 16,777,215 PDUs before the sequence space is exhausted. For most IoT devices, this translates to years of operation. When a node’s sequence counter approaches the maximum, the IV Index Update procedure increments the IV Index and resets SEQ to zero, regenerating the key material and resetting the uniqueness clock without any visible disruption to the network.

Sequence Number Persistence

The SEQ counter must survive power loss. If a node reboots and resumes from the last SEQ it remembered, there is a risk it will reuse a (SEQ, IV Index, SRC) tuple it already transmitted. BlueZ addresses this by writing the SEQ to persistent storage on every increment, and by reading it back with a safety margin on boot:

/*
 * mesh/net.c
 *
 * BlueZ persists the sequence number to flash/disk so that after
 * a power cycle the node resumes from a safe value above what it
 * had already used.
 *
 * The safety margin (SEQ_CACHE_AHEAD) ensures that even if the last
 * few writes were lost before power-off, the resumed counter is still
 * ahead of any previously transmitted value.
 */

#define SEQ_CACHE_AHEAD  128   /* write ahead margin */

/*
 * Called every time a new Network PDU is originated.
 * Returns the SEQ value to use, then increments the counter.
 */
static uint32_t net_seq_next(struct mesh_net *net)
{
    uint32_t seq = net->seq_num++;

    /*
     * Write to storage periodically rather than on every single
     * increment to avoid wearing out flash. We write SEQ + margin.
     * On next boot, net->seq_num is restored from storage (which
     * holds seq + SEQ_CACHE_AHEAD), guaranteeing forward progress.
     */
    if (net->seq_num % 64 == 0)
        mesh_config_write_seq_num(net, net->seq_num + SEQ_CACHE_AHEAD);

    return seq;
}

Replay Protection List (RPL)

The receiving side tracks the highest SEQ observed from each source address. When a PDU arrives from a given SRC, the node checks it against the stored maximum. Any PDU with a SEQ less than or equal to the stored value is treated as a potential replay and dropped.

/*
 * mesh/net.c — Replay Protection List (RPL) check.
 *
 * Each entry in the RPL holds the highest SEQ seen from a given
 * source (SRC) for a given IV Index. The list is persisted to
 * storage so that replays across reboots are also caught.
 *
 * Returns true  → PDU is fresh, update the RPL entry.
 * Returns false → PDU is a replay or stale; discard it.
 */

struct mesh_rpl_entry {
    uint16_t src;
    uint32_t seq;
    uint32_t iv_index;
};

static bool net_rpl_check(struct mesh_net *net,
                           uint16_t src,
                           uint32_t seq,
                           uint32_t iv_index)
{
    const struct l_queue_entry *e;
    struct mesh_rpl_entry      *rpl;

    for (e = l_queue_get_entries(net->rpl_list); e; e = e->next) {
        rpl = e->data;

        if (rpl->src != src)
            continue;

        /* Older IV Index — always a replay */
        if (iv_index < rpl->iv_index)
            return false;

        /* Same IV Index — reject if SEQ is not strictly increasing */
        if (iv_index == rpl->iv_index && seq <= rpl->seq)
            return false;

        /* Fresh — update stored maximum */
        rpl->seq      = seq;
        rpl->iv_index = iv_index;
        return true;
    }

    /* First PDU from this source — add it */
    rpl = l_new(struct mesh_rpl_entry, 1);
    rpl->src      = src;
    rpl->seq      = seq;
    rpl->iv_index = iv_index;
    l_queue_push_tail(net->rpl_list, rpl);
    return true;
}

3.4.4.6 & 3.4.4.7 — SRC and DST: Addressing

SRC and DST are both 16-bit fields. SRC identifies the element that originated the PDU; DST identifies where the PDU is headed. Together these two fields drive almost every routing and relay decision in the network layer.

SRC — Source is Always Unicast

SRC must be a unicast address — the unique 16-bit address assigned to a specific element during provisioning. Only one element originates any given PDU, so group or virtual addresses as SRC are not permitted. Relay nodes pass SRC through untouched; they never substitute their own address. This means the original sender is always identifiable (to nodes that hold the network key) regardless of how many hops the PDU has taken.

DST — Three Address Types

DST is more flexible. The network layer accepts three distinct address types as a destination:

Type Range Who processes it
Unicast 0x0001 – 0x7FFF Exactly one element. Only that element processes the PDU at the access layer.
Group 0xC000 – 0xFEFF All elements that have subscribed to this group address. Enables multicast messaging.
Virtual 0x8000 – 0xBFFF Elements subscribed to a UUID label whose hash matches this address. Allows logical addressing independent of physical provisioning order.
All-nodes 0xFFFF Every node in the network. Used by the provisioner for broadcast commands.

The address validation step happens during the receive path, after decryption. BlueZ checks both SRC and DST before handing the PDU to the lower transport layer:

/*
 * mesh/net.c — address range validation.
 *
 * SRC must be a valid unicast address (0x0001–0x7FFF).
 * DST can be unicast, group, virtual, or the all-nodes address.
 * A zero address (unassigned) is invalid in either field.
 */

#define MESH_ADDR_UNASSIGNED  0x0000
#define MESH_ADDR_UNICAST_MAX 0x7FFF
#define MESH_ADDR_VIRTUAL_MIN 0x8000
#define MESH_ADDR_VIRTUAL_MAX 0xBFFF
#define MESH_ADDR_GROUP_MIN   0xC000
#define MESH_ADDR_GROUP_MAX   0xFEFF
#define MESH_ADDR_ALL_NODES   0xFFFF

static inline bool IS_UNICAST(uint16_t a)
{
    return (a >= 0x0001 && a <= MESH_ADDR_UNICAST_MAX);
}

static inline bool IS_GROUP(uint16_t a)
{
    return (a >= MESH_ADDR_GROUP_MIN && a <= MESH_ADDR_GROUP_MAX);
}

static inline bool IS_VIRTUAL(uint16_t a)
{
    return (a >= MESH_ADDR_VIRTUAL_MIN && a <= MESH_ADDR_VIRTUAL_MAX);
}

static bool net_addr_valid(uint16_t src, uint16_t dst)
{
    /* SRC must always be unicast */
    if (!IS_UNICAST(src)) {
        l_warn("net: invalid SRC 0x%04x (not unicast)", src);
        return false;
    }

    /* DST can be unicast, group, virtual, or all-nodes */
    if (dst == MESH_ADDR_UNASSIGNED) {
        l_warn("net: unassigned DST — discard");
        return false;
    }

    if (!IS_UNICAST(dst) && !IS_GROUP(dst) &&
        !IS_VIRTUAL(dst) && dst != MESH_ADDR_ALL_NODES) {
        l_warn("net: invalid DST 0x%04x", dst);
        return false;
    }

    return true;
}

3.4.4.8 & 3.4.4.9 — TransportPDU and NetMIC

TransportPDU

From the network layer’s standpoint, TransportPDU is an opaque sequence of bytes. The network layer encrypts it but does not interpret its structure — that is entirely the lower transport layer’s responsibility. The CTL bit controls how many bytes TransportPDU can occupy:

CTL Message Category Max TransportPDU NetMIC Size Example Use
0 Access (application) message 128 bits (16 bytes) 32 bits (4 bytes) Light On/Off, sensor data
1 Control message 96 bits (12 bytes) 64 bits (8 bytes) Heartbeat, Friend Poll/Update, LPN messages

Control messages intentionally trade payload space for a larger integrity check. Network management traffic like Friend Poll and Heartbeat must be strongly authenticated because corrupting it could destabilise the entire mesh topology.

NetMIC — Network Message Integrity Check

NetMIC authenticates the DST field and the TransportPDU together. It is computed using AES-CCM with a network nonce built from CTL, TTL, SEQ, SRC, and IV Index. An important detail: the network layer recomputes and re-appends the NetMIC every time it relays a PDU, because the TTL field changes at each hop, which changes the nonce, which changes the expected MIC. A relay node that decrypts, decrements TTL, then re-encrypts with a fresh NetMIC is operating exactly as specified.

/*
 * mesh/crypto.c
 *
 * Network-layer AES-CCM encryption.
 * Encrypts the TransportPDU, authenticates DST, and produces NetMIC.
 *
 * The network nonce is 13 bytes built as follows:
 *   Byte  0    : 0x00  (Network Nonce type identifier)
 *   Byte  1    : CTL(1 bit) | TTL(7 bits)
 *   Bytes 2-4  : SEQ[23:0] big-endian
 *   Bytes 5-6  : SRC big-endian
 *   Bytes 7-8  : 0x00 0x00  (padding)
 *   Bytes 9-12 : IV Index big-endian
 *
 * Additional authenticated data (not encrypted): DST (2 bytes)
 * Encrypted payload                            : TransportPDU
 * Authentication tag appended                  : NetMIC (4 or 8 bytes)
 */
bool mesh_crypto_net_encrypt(const uint8_t enc_key[16],
                              uint8_t       ctl,
                              uint8_t       ttl,
                              uint32_t      seq,
                              uint16_t      src,
                              uint32_t      iv_index,
                              uint16_t      dst,
                              const uint8_t *transport_pdu,
                              uint8_t        transport_len,
                              uint8_t       *out,       /* encrypted TPdu */
                              uint8_t       *out_mic)   /* NetMIC */
{
    uint8_t nonce[13];
    uint8_t dst_bytes[2] = { dst >> 8, dst & 0xff };
    uint8_t mic_len = ctl ? 8 : 4;

    nonce[0]  = 0x00;                           /* Network Nonce type */
    nonce[1]  = ((ctl & 0x01) << 7) | (ttl & 0x7f);
    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]  = 0x00;
    nonce[8]  = 0x00;
    nonce[9]  = (iv_index >> 24) & 0xff;
    nonce[10] = (iv_index >> 16) & 0xff;
    nonce[11] = (iv_index >>  8) & 0xff;
    nonce[12] = (iv_index      ) & 0xff;

    /*
     * AES-CCM call:
     *   key          = Network Encryption Key (EncKey from k2)
     *   nonce        = 13-byte network nonce above
     *   plaintext    = TransportPDU
     *   aad          = DST (2 bytes, authenticated but not encrypted)
     *   mic_len      = 4 (CTL=0) or 8 (CTL=1)
     *   output       = encrypted TransportPDU + NetMIC
     */
    return aes_ccm_encrypt(enc_key, nonce, sizeof(nonce),
                           transport_pdu, transport_len,
                           dst_bytes, sizeof(dst_bytes),
                           out, out_mic, mic_len);
}

Part 2 — Network Interfaces: Bridging Bearers and the Network Layer

A Bluetooth Mesh node can use more than one bearer at the same time: BLE advertising, one or more GATT connections to proxy clients, and an internal loopback path for intra-node messaging. The network layer does not talk to these bearers directly. Each bearer instance connects to the network layer through a network interface — an abstraction that carries input and output filters.

NETWORK LAYER  |  mesh/net.c
Input Filter
↕ Output Filter
Input Filter
↕ Output Filter
Input Filter
↕ Output Filter
Input Filter
↕ Output Filter
Input Filter
↕ Output Filter
Local
Interface
Adv Bearer
Interface
GATT Interface
#1 (Proxy)
GATT Interface
#2 (Proxy)
Additional
Interfaces
Node’s own
elements
(loopback)
BLE Advertising
Bearer
GATT Client 1
via Proxy Service
GATT Client 2
via Proxy Service

3.4.5.1 — Interface Input Filter

Every PDU that arrives from a bearer passes through the interface input filter first. The filter can accept or drop the PDU before the network layer ever sees it. Reasons to drop at input include receiving a PDU that has already been seen (message cache hit), failing the NID check, or violating a bearer-specific policy such as the Proxy filter whitelist or blacklist configured by a connected GATT client.

3.4.5.2 — Interface Output Filter

Outgoing PDUs pass through the output filter before going onto a bearer. The specification mandates one absolute rule: any PDU with TTL set to 1 must be dropped by the output filter of any interface connected to the advertising or GATT bearers. TTL=1 means the PDU should be delivered on the current link but must never be retransmitted further. Enforcing this in the output filter is a safety net that prevents relay nodes from accidentally forwarding a PDU that was only meant to survive one more hop.

/*
 * mesh/net.c — output filter enforcing the TTL=1 drop rule.
 *
 * Called just before a PDU is handed to the advertising bearer
 * or a GATT bearer. The Local Interface bypasses this check
 * because TTL semantics do not apply to intra-node delivery.
 */
static bool output_filter_pass(const uint8_t *pkt, bool is_local_iface)
{
    uint8_t ttl;

    if (is_local_iface)
        return true;   /* Local interface: no TTL restriction */

    /*
     * TTL lives in byte 1 of the PDU after de-obfuscation.
     * At the point this filter runs, the PDU is about to be
     * transmitted and its header is already obfuscated for the air.
     * BlueZ tracks TTL separately in the tx descriptor:
     */
    ttl = pkt[1] & 0x7f;  /* works only before obfuscation; use tx_desc */

    if (ttl == 1) {
        l_debug("net: output filter dropped PDU with TTL=1");
        return false;  /* drop */
    }

    return true;
}

3.4.5.3 — Local Network Interface

The Local Network Interface is mandatory — every node must implement it. When one element on a node wants to talk to another element on the same node, the message goes via the local interface rather than being broadcast over the air. Upon receiving a message through the local interface, the node delivers it to all of its own elements simultaneously.

3.4.5.4 — Advertising Bearer Network Interface

This interface connects the network layer to the BLE advertising bearer. It distinguishes between two categories of outgoing PDU — original transmissions and relayed transmissions — because each uses different retransmission parameters:

PDU Origin Retransmit Parameters Used State (Section)
Originated by this node (not relay) Network Transmit Count + Interval Section 4.2.19
Relayed from another node Relay Retransmit Count + Interval Section 4.2.20
/*
 * mesh/net.c — transmit scheduling for the advertising bearer interface.
 *
 * The 'relay' flag switches between Network Transmit state params
 * and Relay Retransmit state params, as required by sections 4.2.19
 * and 4.2.20 of the Mesh Profile spec.
 */

struct mesh_xmit_params {
    uint8_t count;        /* number of additional transmissions */
    uint8_t interval;     /* steps; actual interval = (steps+1)*10ms */
};

static void adv_iface_queue_pdu(struct mesh_net *net,
                                const uint8_t   *pdu,
                                uint8_t          len,
                                bool             relay)
{
    struct mesh_xmit_params params;

    if (relay) {
        params = net->relay_xmit;  /* Relay Retransmit state */
    } else {
        params = net->net_xmit;    /* Network Transmit state */
    }

    /*
     * Schedule (params.count + 1) total transmissions spaced by
     * (params.interval + 1) * 10 ms.
     */
    mesh_adv_send_pdu(net, pdu, len,
                      params.count,
                      (params.interval + 1) * 10);
}

Part 3 — Relay and Proxy: Extending the Mesh

3.4.6.1 — Relay Feature

Bluetooth Mesh uses managed flooding rather than a routing protocol. There are no routing tables, no route discovery, and no single path between two nodes. Instead, nodes with the Relay feature enabled listen on the advertising bearer and re-broadcast any PDU they receive, with the TTL decremented by one. The PDU propagates outward in all directions simultaneously until every node in the network has seen it or the TTL reaches zero and the last relay stops forwarding.

The spec calls out five conditions that must all be true before a relay node re-broadcasts a PDU:

Condition Requirement
PDU arrived via advertising bearer Relay only applies to advertising bearer input. GATT bearer input has different forwarding rules (Proxy).
Relay feature enabled The Relay state must be ENABLED. Optional feature; must be explicitly turned on.
TTL ≥ 2 TTL is decremented by 1 before forwarding. A TTL of 1 would become 0 after relay, which is invalid on-air. So relay stops at TTL=1.
DST is not a local unicast address If the PDU is destined for an element on this very node, there is no need to relay it over the air.
Random delay recommended When multiple nodes receive the same PDU simultaneously, they may all try to relay at once, causing collisions. A small random delay (typically 20–50 ms) staggers the retransmissions.

3.4.6.2 — Proxy Feature

A GATT-connected device such as a smartphone cannot generate BLE advertising PDUs, so it cannot participate in the mesh directly. The Proxy feature bridges this gap. A Proxy node exposes the Mesh Proxy Service over GATT and forwards PDUs in both directions between the GATT bearer and the advertising bearer (or other GATT connections).

Traffic Direction Arrives Via Forwarded To Required Conditions
Phone → Mesh GATT bearer All network interfaces Proxy enabled, TTL ≥ 2, DST not local
Mesh → Phone Advertising bearer All GATT bearer interfaces Proxy enabled, TTL ≥ 2, DST not local
/*
 * mesh/net.c — relay/proxy decision after upper-layer delivery.
 *
 * This function is called once a PDU has been authenticated,
 * validated, added to the message cache, and handed to the lower
 * transport layer. It then decides whether the PDU should be
 * relayed or proxied onwards.
 */
static void net_decide_relay(struct mesh_net *net,
                              const uint8_t   *pkt,
                              uint8_t          len,
                              bool             from_adv,
                              uint8_t          ttl,
                              uint16_t         dst)
{
    bool relay_on = (net->relay.mode == MESH_MODE_ENABLED);
    bool proxy_on = (net->proxy.mode == MESH_MODE_ENABLED);

    /* Never relay a PDU destined for an element on this node */
    if (mesh_net_is_local_unicast(net, dst))
        return;

    if (ttl < 2)
        return;   /* TTL too low to relay */

    if (from_adv && relay_on) {
        /*
         * Classic relay: re-broadcast via advertising bearer.
         * A random delay (20-50 ms) is introduced to reduce
         * simultaneous retransmission collisions from nearby
         * relay nodes that heard the same PDU.
         */
        uint32_t delay_ms = 20 + (l_getrandom_uint32() % 30);
        l_timeout_create_ms(delay_ms, relay_adv_timeout, pkt, NULL);
        return;
    }

    if (from_adv && proxy_on) {
        /* Mesh -> Phone: forward advertising PDU to GATT interfaces */
        mesh_net_relay_to_gatt(net, pkt, len);
    }

    if (!from_adv && proxy_on) {
        /* Phone -> Mesh: forward GATT PDU to all interfaces */
        mesh_net_relay_to_all(net, pkt, len);
    }
}

Part 4 — Receiving a Network PDU: The Full Pipeline

Every PDU that arrives at the network layer must pass through a strict sequence of validation steps. Any failure at any step results in a silent discard — the spec deliberately avoids error responses at the network layer to prevent information leakage. Here is the complete receive flowchart from the spec, rendered as an inline diagram:

📡 Packet Received
via Mesh Bearer
NID matches a known
Network Key?
← NO: Discard
↓ YES
NetMIC authenticates
against matched NetKey?
← NO: Discard
↓ YES
SRC and DST
addresses valid?
← NO: Discard
↓ YES
Already in Network
Message Cache?
← YES: Discard
↓ NOT IN CACHE
💾 Add to Network Message Cache
⇧ Forward to Lower Transport Layer
Must relay?
(TTL≥2, DST not local,
Relay/Proxy enabled)
← NO: END
↓ YES
⇄ Decrement TTL, Tag as Relay,
Retransmit on Target Interfaces
→ END

Step 1 — NID Check

The NID (Network ID, 7 bits) is derived from the network key via the k2() key derivation function. A node stores one NID per subnet it belongs to. When a PDU arrives, the node checks whether the NID in byte 0 matches any stored NID. If it does not match anything, the PDU is discarded immediately without any decryption attempt.

Step 2 — Authentication

For every network key whose NID matched, the node attempts to verify the NetMIC. This involves de-obfuscating the header, rebuilding the network nonce, and running AES-CCM decryption. If no key produces a valid NetMIC, the PDU is discarded. If multiple network keys share the same NID (a collision is possible because NID is only 7 bits), all of them are tried.

Step 3 — Address Validation

After successful authentication, SRC and DST are in plaintext. The node confirms that SRC is a valid unicast address and DST is any valid address type. A zero (unassigned) address in either field causes a discard.

Step 4 — Network Message Cache

The message cache is what prevents the flood-relay mechanism from becoming an infinite loop. Without it, every relay node would re-broadcast to all its neighbours, who would relay again, indefinitely. The cache stores (SRC, SEQ) pairs. Any PDU whose pair is already present is dropped as a duplicate.

/*
 * mesh/net.c — Network Message Cache.
 *
 * BlueZ uses a fixed-size circular buffer. When the buffer is full,
 * the oldest entry is overwritten. Cache size is defined at compile
 * time; 32 entries is typical for embedded targets.
 *
 * Entries store SRC and SEQ. The IV Index is implicitly the current
 * or previous index, which is handled at the authentication step.
 */

#define NET_MSG_CACHE_SIZE  32

struct msg_cache_entry {
    uint16_t src;
    uint32_t seq;
    bool     valid;
};

static struct msg_cache_entry msg_cache[NET_MSG_CACHE_SIZE];
static uint8_t                msg_cache_next = 0;

/*
 * Returns true  = already in cache (duplicate — discard).
 * Returns false = new entry added  (process this PDU).
 */
static bool net_msg_cache_check_add(uint16_t src, uint32_t seq)
{
    int i;

    /* Linear scan of all valid entries */
    for (i = 0; i < NET_MSG_CACHE_SIZE; i++) {
        if (msg_cache[i].valid &&
            msg_cache[i].src == src &&
            msg_cache[i].seq == seq) {
            return true;   /* duplicate — discard */
        }
    }

    /* Not found — insert at next slot (overwrites oldest on wrap) */
    msg_cache[msg_cache_next].src   = src;
    msg_cache[msg_cache_next].seq   = seq;
    msg_cache[msg_cache_next].valid = true;
    msg_cache_next = (msg_cache_next + 1) % NET_MSG_CACHE_SIZE;

    return false;   /* fresh PDU */
}

IV Index: Handling the Transition Window

During an IV Index Update, two IV Index values are simultaneously valid: the current one and the one before it. The IVI bit in byte 0 of the Network PDU (the MSB) indicates which IV Index the sender used. The receiver checks the IVI bit against the LSB of both IV Index values and tries authentication with whichever matches. This ensures that PDUs sent just before an IV Index transition are not lost:

/*
 * mesh/net.c — dual IV Index authentication.
 *
 * During IV Index Update, the node accepts PDUs encrypted with
 * either the current IV Index or (current - 1).
 *
 * The IVI bit (MSB of byte 0) equals the LSB of the IV Index
 * that was used for encryption, acting as a cheap first-pass hint.
 */
static bool net_pkt_authenticate(struct mesh_net *net,
                                  const uint8_t  *pkt,
                                  uint8_t         len,
                                  uint32_t       *out_iv,
                                  struct mesh_subnet **out_sub)
{
    uint8_t  ivi     = (pkt[0] >> 7) & 0x01;
    uint8_t  nid     = pkt[0] & 0x7f;
    uint32_t cur_iv  = net->iv_index;
    uint32_t prev_iv = cur_iv - 1;

    /* Try current IV Index first */
    if ((cur_iv & 0x01) == ivi) {
        if (subnet_try_auth(net, nid, pkt, len, cur_iv, out_sub)) {
            *out_iv = cur_iv;
            return true;
        }
    }

    /* Fall back to previous IV Index (valid only during update window) */
    if (net->iv_update && (prev_iv & 0x01) == ivi) {
        if (subnet_try_auth(net, nid, pkt, len, prev_iv, out_sub)) {
            *out_iv = prev_iv;
            return true;
        }
    }

    return false;  /* authentication failed */
}

Part 5 — Transmitting a Network PDU

The transmit path is where the network layer builds a PDU from scratch. It sets every field, runs AES-CCM encryption, applies header obfuscation, and hands the final encrypted blob to the advertising bearer interface. The ordering matters — you cannot obfuscate the header before you have encrypted the payload, because the PECB (the obfuscation mask) is derived from the encrypted payload bytes.

Step Action Detail
1 Set IVI Least significant bit of the current IV Index for the subnet in use.
2 Set NID NID derived from the network key using the k2() function. Stored per subnet in subnet→nid.
3 Set CTL, TTL CTL is set by the higher (transport) layer. TTL is typically the node’s Default TTL state value.
4 Allocate SEQ Atomically read and increment the element’s sequence counter. Persist the new value to storage.
5 Set SRC, DST SRC = this element’s unicast address. DST = destination from the transport layer.
6 AES-CCM Encrypt Encrypt TransportPDU, authenticate DST, produce NetMIC. Uses the network Encryption Key and the network nonce.
7 Obfuscate Header XOR bytes 1–6 (CTL|TTL, SEQ, SRC) with PECB. PECB is derived from Privacy Key + IV Index + first bytes of encrypted payload.
8 Hand to Bearer Pass the fully constructed PDU to the advertising bearer interface. Interface applies output filter (drops TTL=1), then transmits.
/*
 * mesh/net.c — full Network PDU construction and transmission.
 *
 * Called by the lower transport layer with a ready TransportPDU.
 * Returns true on success, false if the subnet is not found or
 * encryption fails.
 */
bool mesh_net_send_pkt(struct mesh_net *net,
                        uint8_t          ctl,
                        uint8_t          ttl,
                        uint16_t         src,
                        uint16_t         dst,
                        const uint8_t   *transport_pdu,
                        uint8_t          transport_len,
                        uint32_t         net_key_idx)
{
    struct mesh_subnet *subnet;
    uint8_t  pdu[29];        /* max Network PDU: 1+1+3+2+2+16+8 = 33,
                              * practical max ~29 bytes for most cases */
    uint8_t  pdu_len;
    uint32_t seq, iv_index;
    uint8_t  mic_len;

    subnet = mesh_net_get_subnet(net, net_key_idx);
    if (!subnet)
        return false;

    iv_index = net->iv_index;
    mic_len  = ctl ? 8 : 4;

    /* Step 4: read-and-increment sequence counter (atomic in single thread) */
    seq = net_seq_next(net);

    /* Step 1+2: byte 0 = IVI(1) | NID(7) */
    pdu[0] = ((iv_index & 0x01) << 7) | (subnet->nid & 0x7f);

    /* Step 3: byte 1 = CTL(1) | TTL(7) — will be obfuscated in step 7 */
    pdu[1] = ((ctl & 0x01) << 7) | (ttl & 0x7f);

    /* Step 4: bytes 2-4 = SEQ big-endian */
    pdu[2] = (seq >> 16) & 0xff;
    pdu[3] = (seq >>  8) & 0xff;
    pdu[4] = (seq      ) & 0xff;

    /* Step 5: bytes 5-6 = SRC big-endian */
    pdu[5] = (src >> 8) & 0xff;
    pdu[6] = (src     ) & 0xff;

    /*
     * Step 6: AES-CCM encryption.
     * Output layout starting at pdu[7]:
     *   [2 bytes DST encrypted/authenticated] [N bytes encrypted TransportPDU] [4 or 8 bytes NetMIC]
     */
    if (!mesh_crypto_net_encrypt(subnet->enc_key,
                                  ctl, ttl, seq, src, iv_index,
                                  dst,
                                  transport_pdu, transport_len,
                                  pdu + 7,                            /* encrypted output */
                                  pdu + 7 + 2 + transport_len))       /* NetMIC position */
        return false;

    pdu_len = 7 + 2 + transport_len + mic_len;

    /*
     * Step 7: Header obfuscation.
     * XOR bytes 1-6 (CTL|TTL, SEQ[0-2], SRC[0-1]) in-place.
     * PECB is computed from Privacy Key, IV Index, and the first
     * 7 bytes of the encrypted portion (starting at pdu[7]).
     */
    mesh_crypto_obfuscate(subnet->priv_key, iv_index,
                           pdu + 7,    /* encrypted payload start */
                           pdu + 1);   /* header bytes to obfuscate */

    /* Step 8: Send via advertising bearer interface (not a relay PDU) */
    adv_iface_queue_pdu(net, pdu, pdu_len, false);

    return true;
}

Header Obfuscation in Detail

Header obfuscation is what hides TTL and SRC from anyone who does not hold the network key. It works by XOR-ing the six header bytes (CTL|TTL plus SEQ plus SRC) with a PECB derived by running AES-ECB over a privacy plaintext block. The privacy plaintext block is built from: five zero-padding bytes, the IV Index (4 bytes), and the first seven bytes of the encrypted payload (which includes the encrypted DST).

/*
 * mesh/crypto.c — header obfuscation (Privacy Key application).
 *
 * Privacy Plaintext Block layout (16 bytes total):
 *   Bytes 0-4:  0x00 0x00 0x00 0x00 0x00  (zero padding)
 *   Bytes 5-8:  IV Index big-endian
 *   Bytes 9-15: First 7 bytes of encrypted payload (enc_dst_start)
 *
 * AES-ECB(PrivacyKey, privacy_plaintext) → 16 byte PECB.
 * XOR the first 6 bytes of PECB with header bytes 1-6 of the PDU.
 */
bool mesh_crypto_obfuscate(const uint8_t priv_key[16],
                            uint32_t      iv_index,
                            const uint8_t *enc_payload, /* 7+ bytes */
                            uint8_t       *hdr)          /* 6 bytes, in-place */
{
    uint8_t privacy_plaintext[16] = { 0 };
    uint8_t pecb[16];
    int     i;

    /* Build privacy plaintext — first 5 bytes remain zero (padding) */
    privacy_plaintext[5] = (iv_index >> 24) & 0xff;
    privacy_plaintext[6] = (iv_index >> 16) & 0xff;
    privacy_plaintext[7] = (iv_index >>  8) & 0xff;
    privacy_plaintext[8] = (iv_index      ) & 0xff;
    memcpy(privacy_plaintext + 9, enc_payload, 7);

    /* AES-ECB block encryption with Privacy Key */
    if (!aes_ecb_one_block(priv_key, privacy_plaintext, pecb))
        return false;

    /* XOR PECB[0:5] with PDU header bytes 1-6 */
    for (i = 0; i < 6; i++)
        hdr[i] ^= pecb[i];

    return true;
}

/*
 * De-obfuscation is identical: XOR undoes XOR.
 * Call the same function with the obfuscated header bytes
 * to recover the plaintext CTL|TTL, SEQ, SRC fields.
 */
bool mesh_crypto_deobfuscate(const uint8_t priv_key[16],
                              uint32_t      iv_index,
                              const uint8_t *enc_payload,
                              uint8_t       *hdr)
{
    /* Obfuscation and de-obfuscation are the same XOR operation */
    return mesh_crypto_obfuscate(priv_key, iv_index, enc_payload, hdr);
}

Summary — What You Have Learned

Concept Key Takeaway
SEQ (24-bit) Monotonically increasing per element. (SEQ, IV Index, SRC) must be globally unique. Must be persisted to survive power loss.
SRC (16-bit) Always a unicast address. Set by the originating element; relay nodes pass it through unchanged.
DST (16-bit) Unicast, group, or virtual address. Determines whether the network layer relays or delivers locally.
CTL bit Controls TransportPDU size limit (128 vs 96 bits) and NetMIC size (32 vs 64 bits) simultaneously.
NetMIC AES-CCM authentication tag covering DST and TransportPDU. Recomputed at every relay hop because TTL changes the nonce.
Network Interfaces Abstraction between the network layer and each bearer instance. Each interface has input and output filters. Local interface is mandatory.
Output Filter Rule PDUs with TTL=1 are dropped before reaching any advertising or GATT bearer. Prevents unintended relay of end-of-life PDUs.
Relay Feature Flood-based forwarding from advertising bearer only. TTL decremented by 1 per hop. Random delay recommended to avoid collision storms.
Proxy Feature Bridges GATT clients (smartphones) into the mesh. Forwards PDUs between GATT bearer and advertising bearer in both directions.
Network Message Cache Circular (SRC, SEQ) buffer that prevents relay loops and duplicate upper-layer delivery. Essential for the flood model to terminate.
IV Index window During IV Update, nodes authenticate against both current and previous IV Index. The IVI bit acts as a hint to select the right one first.
Header Obfuscation Privacy Key + IV Index + encrypted payload bytes → PECB via AES-ECB. XOR with CTL|TTL, SEQ, SRC hides source and hop-count from passive observers.

Continue Your Bluetooth Mesh Journey

You now have a complete picture of how the Bluetooth Mesh network layer builds, encrypts, relays, and validates Network PDUs. The next post in this series covers the Lower Transport Layer — segmentation and reassembly of large messages, the SAR timeout mechanism, and the Friend/LPN friendship establishment that allows low-power nodes to sleep between polls.

Leave a Reply

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