Overview
Part 2 of this series built an STM32 SX1262 encrypted LoRa link with two separate working keys: an encKey for AES-CTR encryption and a macKey for AES-CMAC authentication. Using distinct keys for the two jobs is a deliberate security rule. But Part 2 was honest about a gap: the function meant to produce those two keys, radioLinkDeriveKeys(), was a placeholder. It simply copied the master key into both slots, so all three keys were identical.
Part 4 closes that gap. It replaces the placeholder with a real key derivation function (KDF) built on AES-CMAC, so encKey and macKey are now genuinely distinct and cryptographically independent — both derived from the master key, but neither recoverable from the other. This is the piece that completes the Part 2 security design.
The change is small and contained: two functions in one file, radioLink/radio_link.c. The full project source and Doxygen documentation are available to download in the Project Downloads section below.
What You Will Learn
- Why encryption and authentication must use separate keys
- What a key derivation function (KDF) is and the problem it solves
- How a NIST SP 800-108 counter-mode KDF is structured
- How AES-CMAC is used as the pseudorandom function inside the KDF
- How distinct “ENC” and “MAC” labels make the derived keys independent
- Why node identity is deliberately excluded from the derivation
- How the finished
radioLinkDeriveKeys()replaces the Part 2 placeholder
Prerequisites
This tutorial builds on the earlier parts of the series. You should be comfortable with the material in Part 1 and Part 2, in particular:
- AES-CTR encryption and AES-CMAC authentication as used by the Wire v3 protocol
- The
radioLinkCryptoCtx_tstructure and its three key fields - The placeholder
radioLinkDeriveKeys()discussed in Part 2 - STM32CubeIDE, the STM32 HAL, and building/flashing the project
The cryptographic concepts — key separation, key derivation, pseudorandom functions — are explained from a practical standpoint as they come up. A link to the formal specification is provided for readers who want the full detail.
Materials List
- STM32 Nucleo-F439ZI development board (2× — one TX, one RX)
- SX1262 LoRa module (2×)
- Jumper wires and breadboard
- Two USB cables for power and ST-LINK debugging
- A serial terminal on each board for log output
- STM32CubeIDE (for building and flashing the project)
Project Structure
The project layout is the same one used throughout the series. The only file changed for this part is radioLink/radio_link.c, which holds the key-derivation code discussed below.
F439_CPP_TX-RX_LoRa_Project_01/
├── ADA1897_MB85RS64B/ <-- SPI FRAM driver (persistence layer)
│ ├── ada1897_mb85rs64b.c
│ └── ada1897_mb85rs64b.h
├── Core/ <-- CubeMX-generated HAL + main.c
│ ├── Inc/
│ │ ├── main.h
│ │ ├── stm32f4xx_hal_conf.h
│ │ └── stm32f4xx_it.h
│ ├── Src/
│ │ ├── main.c
│ │ ├── stm32f4xx_hal_msp.c
│ │ ├── stm32f4xx_it.c
│ │ ├── syscalls.c
│ │ ├── sysmem.c
│ │ └── system_stm32f4xx.c
│ ├── Startup/
│ └── ThreadSafe/
├── Drivers/ <-- ST HAL + CMSIS (CubeMX-managed)
├── SX1262/ <-- SX1262 LoRa radio driver
│ ├── sx1262.c
│ └── sx1262.h
├── radioApp/ <-- Application layer
│ ├── radio_app.c
│ └── radio_app.h
├── radioLink/ <-- Protocol layer (Wire v3, crypto, replay)
│ ├── radio_link.c
│ ├── radio_link.h
│ └── radio_wire.h
├── qaTests/ <-- Hostile-frame QA harness
│ ├── protocol/
│ └── qaApp/
├── docs/ <-- Doxygen output
├── scripts/ <-- Build / instrumentation helpers
├── F439_CPP_TX-RX_LoRa_Project_01.ioc
├── STM32F439ZITX_FLASH.ld
├── Doxyfile
└── README.md
Hardware Configuration / Pinouts
Overview
Part 4 introduces no hardware changes. The wiring is identical to the earlier parts: two boards, each with an SX1262 module on SPI, and the on-chip CRYP peripheral handling AES. Key derivation is a pure software change — it runs on the same AES engine already configured in Part 1.
Pinouts & Configurations
The full pin assignment and clock configuration for the project is exported from STM32CubeMX. Download the PDF below for the complete reference; it is unchanged from the earlier parts of the series.
[INSERT FILE BLOCK HERE — IOC PDF]
Project Setup
If you followed the earlier parts, the project is already set up. Part 4 adds no new CubeMX peripherals and no new files — it modifies one existing function. To follow along, open radioLink/radio_link.c alongside this tutorial and locate the section marked RADIOLINK_KEY_DERIVATION.
Code Walkthrough
The walkthrough first revisits the problem Part 2 left open, then explains the KDF design at a practical level, then walks the two functions that implement it, and finally shows where they are called.
The Problem: Two Keys, One Placeholder
Part 2 established that the link needs two separate keys. The crypto context structure has fields for all of them:
typedef struct radioLinkCryptoCtx_t {
uint8_t masterKey[16];
uint8_t encKey[16];
uint8_t macKey[16];
uint8_t keyIsValid; // 0/1
} radioLinkCryptoCtx_t;There is a masterKey — the long-term secret — and two working keys: encKey for AES-CTR and macKey for AES-CMAC. The reason they must differ is a core principle of applied cryptography: a single key should be used for exactly one purpose. Reusing one key for both encryption and authentication can create subtle interactions where the output of one operation weakens the other. Keeping the keys separate keeps the two operations cryptographically independent.
Part 2 shipped this structure with the right shape, but the function that filled it was a placeholder:
// Placeholder: copy masterKey into encKey/macKey so we have deterministic bytes.
// Real implementation will derive separate keys (ENC/MAC labels) using CMAC.
for (uint32_t i = 0u; i < 16u; i++) {
ctx->encKey[i] = ctx->masterKey[i];
ctx->macKey[i] = ctx->masterKey[i];
}This copied the master key verbatim into both working keys. The result: encKey, macKey, and masterKey were all the same sixteen bytes. The structure promised key separation; the code did not deliver it. Part 4 replaces this placeholder with a real derivation.
The Design: An SP 800-108 KDF Using AES-CMAC
A key derivation function takes one input key and produces one or more output keys from it. A good KDF guarantees that the outputs are independent: even someone who learns one derived key — and even someone who knows the derivation method — cannot work backward to the master key or sideways to the other derived key.
The construction used here follows NIST SP 800-108, the standard for deriving keys from an existing key, in its Counter Mode. Counter Mode builds each block of output by running a pseudorandom function (PRF) over a structured input. A PRF is any keyed function whose output is indistinguishable from random to anyone without the key — and AES-CMAC is a PRF. Since the project already implements AES-CMAC for frame authentication (Part 2), using it as the KDF’s PRF means no new cryptographic primitive is introduced: the entire crypto surface remains AES.
In SP 800-108 Counter Mode, the PRF input for each output block is a fixed structure:
+---------+----------+-----------+---------+
| [i]_32 | Label | 0x00 sep | [L]_32 |
| 4 bytes | 3 bytes | 1 byte | 4 bytes |
+---------+----------+-----------+---------+
00000001 "ENC"/"MAC" 00 00000080
[i]_32 = counter, 1 for a single 128-bit output block
[L]_32 = output length in BITS (128 = 0x00000080)
total PRF input = 12 bytes; AES-CMAC over it yields the 16-byte keyReading the fields: [i]_32 is a 32-bit big-endian counter identifying which output block is being produced — only ever 1 here, because a single AES-CMAC call yields 16 bytes and the project needs exactly one 128-bit key per derivation. Label is an ASCII string naming the key being derived. The 0x00 separator is mandated by the standard to unambiguously mark the end of the label. [L]_32 is the desired output length in bits — 128, encoded as 0x00000080.
The label is the critical field. Deriving one key with the label "ENC" and another with "MAC" means the two PRF inputs differ, so the two outputs are independent pseudorandom values — even though both derivations use the same master key. That is precisely the key separation the Part 2 design called for. Readers who want the formal treatment — including Feedback and Double-Pipeline modes, and the security analysis — can consult the specification directly: NIST SP 800-108r1.
The KDF Function
With the design understood, the implementation is straightforward. radioLinkKdfDeriveKey() derives a single 128-bit key. It assembles the PRF input exactly as the layout above describes, then runs AES-CMAC over it:
static void radioLinkKdfDeriveKey(const uint8_t masterKey[16],
const char *label,
uint32_t labelLen,
uint8_t outKey[16])
{
/* PRF input: [i]_32 (4) + label + 0x00 (1) + [L]_32 (4) */
uint8_t prfInput[4u + 8u + 1u + 4u];
uint32_t pos = 0u;
uint32_t i;
if ((masterKey == NULL) || (label == NULL) || (outKey == NULL)) {
return;
}
/* Cap label so prfInput cannot overflow (ENC/MAC are 3 bytes). */
if (labelLen > 8u) {
labelLen = 8u;
}
/* [i]_32 = 1, big-endian: single 128-bit output block. */
prfInput[pos++] = 0x00u;
prfInput[pos++] = 0x00u;
prfInput[pos++] = 0x00u;
prfInput[pos++] = 0x01u;
/* Label */
for (i = 0u; i < labelLen; i++) {
prfInput[pos++] = (uint8_t)label[i];
}
/* 0x00 separator */
prfInput[pos++] = 0x00u;
/* [L]_32 = 128 bits, big-endian */
prfInput[pos++] = 0x00u;
prfInput[pos++] = 0x00u;
prfInput[pos++] = 0x00u;
prfInput[pos++] = 0x80u;
radioLinkAesCmac128(masterKey, prfInput, pos, outKey);
}Walking it: the function builds the PRF input into a small stack buffer, field by field. The counter [i]_32 is written as the four bytes 00 00 00 01. The label bytes are copied in next, with a guard that caps the label length so the fixed-size buffer cannot overflow — the labels in use are three bytes, well within the limit. The mandatory 0x00 separator follows. Finally the length field [L]_32 is written as 00 00 00 80 — 128 in bits. The assembled input is then passed to radioLinkAesCmac128(), the same CMAC function used for frame authentication, and its 16-byte output is the derived key.
Deriving Both Working Keys
radioLinkDeriveKeys() is the function Part 2 left as a placeholder. It now calls the KDF twice — once per working key, each with its own label:
static void radioLinkDeriveKeys(radioLinkCryptoCtx_t *ctx, uint8_t nodeId)
{
static const char encLabel[] = "ENC";
static const char macLabel[] = "MAC";
(void)nodeId;
if (ctx == NULL) {
return;
}
radioLinkKdfDeriveKey(ctx->masterKey, encLabel,
(uint32_t)(sizeof(encLabel) - 1u),
ctx->encKey);
radioLinkKdfDeriveKey(ctx->masterKey, macLabel,
(uint32_t)(sizeof(macLabel) - 1u),
ctx->macKey);
ctx->keyIsValid = 1u;
}The "ENC" label derives encKey; the "MAC" label derives macKey. Same master key, two different labels, two independent results. Setting keyIsValid signals that the context is ready for use.
One detail worth explaining is the (void)nodeId; line. The function still accepts a nodeId parameter, but deliberately does not use it. An earlier revision mixed the node identifier into the KDF input as derivation context. That broke the link: this is a shared-key system, so the transmitter and receiver must derive identical working keys. If each node mixed in its own identity, the two ends would derive different keys and every frame would fail authentication. Excluding node identity is therefore correct here — the label is the only thing that varies between the two keys, and both nodes derive the same pair. The parameter is kept only so the call site does not have to change.
Where Derivation Happens
The keys are derived once, lazily, the first time the crypto layer is needed. radioLinkCryptoEnsureInit() handles that:
static void radioLinkCryptoEnsureInit(void)
{
uint8_t nodeId;
if (gRlCryptoInitDone != 0u) {
return;
}
nodeId = RadioLink_GetNodeId();
memcpy(gRlCryptoCtx.masterKey, gRadioLinkMasterKey_Default, sizeof(gRlCryptoCtx.masterKey));
radioLinkDeriveKeys(&gRlCryptoCtx, nodeId);
gRlCryptoInitDone = 1u;
}On the first call it copies the default master key into the context and calls radioLinkDeriveKeys(); the gRlCryptoInitDone flag ensures every later call is a no-op. From that point on, the rest of the protocol code reads encKey and macKey — never masterKey directly — so completing the derivation required no changes anywhere else in the protocol layer. The structure was already shaped for the final design in Part 2; Part 4 only had to fill it correctly.
With the real KDF in place, the encrypted LoRa link now has genuine key separation: an independent encryption key and authentication key, both derived from a single master secret using the AES primitive the project already relies on. The Part 2 security design is complete.
Project Downloads
The complete project source code used in this tutorial is available for download. This includes all necessary files to build and run the project, along with supporting documentation.
- Full STM32CubeIDE Project (Source + Configuration)
- Doxygen Documentation (docs/html/index.html)
- The completed
radioLinkDeriveKeys()AES-CMAC key derivation
[INSERT FILE BLOCK HERE — PROJECT ZIP]
[INSERT FILE BLOCK HERE — DOXYGEN DOCUMENTATION]
If project source is not linked in the tutorial, it may be available on request — use the email contact option in the site footer.
Documentation
This project includes full Doxygen-generated documentation for all custom source files, including the RadioLink security model and the key-derivation function described in this tutorial.
The documentation is included within the project download and can be viewed locally by opening:
F439_CPP_TX-RX_LoRa_Project_01/docs/html/index.html
in a web browser.
The documentation provides detailed descriptions of functions, data structures, and module interactions to assist with understanding and extending the project.
If you have questions or run into trouble getting the boards programmed and talking to each other, post in the Tutorial Support forum and I will work through it with you. If project source is not linked in the tutorial, it may be available on request — use the email contact option in the site footer.


