Overview
This is the fifth and final part of the STM32 SX1262 encrypted LoRa series, and it closes a gap left open in Part 3. The QA harness introduced in Part 3 was documented to cover seven hostile-frame and boundary cases, but only four of those builders existed in code at the time — replay, maximum-payload, oversized-payload, and invalid-node Id. The three remaining builders were named in the verification documentation and marked as not yet implemented.
Those three builders have now been written, reviewed, and added to the project. This tutorial walks through them: a truncated frame, a frame-length-mismatch frame, and a corrupted-CMAC frame. With these in place, the harness in code finally matches the harness the Doxygen verification page always described.
The authentication mechanism these tests probe, AES-CMAC, is specified in RFC 4493.
What You Will Learn
- Why the QA harness was completed in two stages, and what the three new builders add.
- How each hostile-frame builder constructs a valid Wire v3 frame and then applies one targeted corruption.
- Which RX rejection gate each corruption is designed to exercise — structural length validation or CMAC verification.
- How to read the QA log output, given that these tests are exploratory and do not produce an automated pass/fail verdict.
Prerequisites
This tutorial assumes you have followed the earlier parts of the series, in particular Part 2 (the Wire v3 frame format, AES-CTR encryption, CMAC authentication, and replay protection) and Part 3 (the QA harness structure, the RADIOLINK_QA_TEST build switch, and the TX/RX verification model). The three builders covered here plug into that same harness, so the harness mechanics are not repeated in depth.
You should be comfortable reading C, building and flashing an STM32 project with STM32CubeIDE, and using a serial terminal to observe log output.
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’s ST-LINK VCP, to read the QA log output
- STM32CubeIDE (for building and flashing the project)
The hardware is unchanged from the rest of the series. The three new builders are pure software additions to qaTests/protocol/ and require no wiring changes.
Project Structure
The project layout is the same one used throughout the series. The directory that matters for this tutorial is qaTests/: qaTests/protocol/ holds the hostile-frame builders (protocol_qa.c and protocol_qa.h), and qaTests/qaApp/ holds the harness loop that drives them. The three builders described here are additions to protocol_qa.c; no other directory changes.
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
There is no new hardware in this tutorial. The wiring is identical to Parts 1 through 4: two boards, each with an SX1262 module on the SPI bus, control lines (CS, BUSY, NRESET, DIO1) on GPIO, and the on-chip CRYP peripheral handling AES. One board is flashed as the TX node and one as the RX node, the same role split the harness has used since Part 3.
Everything in this part is a software change confined to the QA builders. The build-time switch that selects the QA harness over the production loop is also unchanged from Part 3.
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.
Project Setup
If you followed the earlier parts, the project is already set up. The three new builders ship in the same project package, and no CubeMX changes are needed. To follow along:
- Download and extract the project package linked at the end of this tutorial.
- Open the project in STM32CubeIDE.
- Open
qaTests/protocol/protocol_qa.calongside this tutorial — the walkthrough refers to it throughout. - Confirm the QA harness build switch is enabled, exactly as described in Part 3.
- Have a serial terminal ready on each board so you can watch the QA log lines as frames are sent and received.
As a reminder from Part 3, the harness is selected at build time in Core/Src/main.c by defining RADIOLINK_QA_TEST. When it is defined, main() runs QaApp_Loop() instead of the production RadioApp_Loop(), and DIO1 interrupt events are routed to the QA handler.
Code Walkthrough
The three builders covered here all live in qaTests/protocol/protocol_qa.c. Each one follows the same two-step shape: build a structurally valid Wire v3 frame, then apply exactly one corruption. That single corruption is what makes the frame hostile, and it is chosen to probe one specific gate in the RX parser. Because the builders are so similar, this walkthrough shows the first one in full and then focuses on just the corrupting step for the other two.
The Shared Frame Builder
Every hostile-frame builder starts from a valid frame, and that valid frame is produced by a single internal helper, ProtocolQa_BuildSmallFrame(). It is a thin wrapper around the production frame formatter RadioLink_BuildWireV3Frame() from the protocol layer. It is not itself a test case — it is shared plumbing — but it is worth seeing once because all three builders depend on it.
static bool ProtocolQa_BuildSmallFrame(uint8_t *out,
uint8_t outMax,
uint8_t *outLen,
const uint8_t *payload,
uint8_t payloadLen,
uint32_t sessionSeqId,
uint32_t msgCounter)
{
return RadioLink_BuildWireV3Frame(out,
outMax,
1U,
sessionSeqId,
msgCounter,
payload,
payloadLen,
outLen);
}The fixed 1U argument is the node identifier. The helper produces a complete, correctly-formed frame: an 11-byte Wire v3 header, the encrypted payload, and a valid 16-byte CMAC tag computed over the header and ciphertext. Because the frame leaves this helper valid, the RX parser would accept it as-is. Each builder below takes that valid frame and breaks it in one deliberate way.
Group A: Probing Structural Validation
The first thing the RX parser does with a received frame, before any cryptography, is check that the frame is structurally sane: long enough to contain a header and a tag, and internally consistent between its declared payload length and its actual received length. The first two builders attack exactly those checks. Neither frame should ever reach the CMAC stage; both should be rejected on length grounds alone.
Truncated frame. ProtocolQa_BuildTruncatedFrame() builds a valid frame and then reports a length shorter than the minimum a Wire v3 frame can legally be. It is shown here in full, since it establishes the build-then-corrupt pattern that the other two builders reuse.
bool ProtocolQa_BuildTruncatedFrame(uint8_t *out,
uint8_t outMax,
uint8_t *outLen,
uint32_t sessionSeqId,
uint32_t msgCounter)
{
static const uint8_t qaPayload[] = "QA truncated";
uint8_t fullLen = 0U;
if (outLen == NULL) {
return false;
}
if (!ProtocolQa_BuildSmallFrame(out,
outMax,
&fullLen,
qaPayload,
(uint8_t)(sizeof(qaPayload) - 1U),
sessionSeqId,
msgCounter)) {
return false;
}
(void)fullLen;
*outLen = (uint8_t)(RADIOLINK_WIRE_V3_HDR_LEN_DERIVED - 1U);
return true;
}The corruption is the single line *outLen = RADIOLINK_WIRE_V3_HDR_LEN_DERIVED - 1U. The frame buffer itself is left fully intact — the builder simply lies about how many bytes the harness should transmit. RADIOLINK_WIRE_V3_HDR_LEN_DERIVED is 11, so the harness sends only 10 bytes: less than even a bare header, and far below the 27-byte minimum (11-byte header plus 16-byte tag). The real length computed by the helper is captured in fullLen and then deliberately discarded with (void)fullLen.
On the RX side, this frame is rejected by the parser’s minimum-length check before any cryptographic work is attempted. The frame is too short to contain a complete header and tag, so structural validation fails immediately and the parser returns a rejection.
Frame length mismatch. ProtocolQa_BuildLengthMismatchFrame() follows the same shape: a null-pointer guard, a call to ProtocolQa_BuildSmallFrame(), and then a corruption. Only the corrupting step differs, and that is what is shown here.
*declaredPayloadLen = (uint8_t)(originalPayloadLen + 1U);
out[RL_W3_OFF_PAYLOAD_LEN] = *declaredPayloadLen;After a valid frame is built, this builder overwrites the payload-length byte in the header with a value one greater than the payload the frame actually carries. RL_W3_OFF_PAYLOAD_LEN is the offset of that header field. The total number of bytes transmitted is unchanged; what changes is that the header now claims a payload one byte longer than what is really present.
This targets the parser’s length-consistency check. The RX side recomputes the frame length it expects from the declared payload length (header plus declared payload plus tag) and compares it against the number of bytes actually received. Because the declared payload length was bumped by one, those two values no longer agree, and the frame is rejected at structural validation, again before any CMAC check runs.
Group B: Probing CMAC Verification
A frame that is structurally valid still has to pass authentication. After the header checks succeed, the RX parser computes a CMAC tag over the received header and ciphertext and compares it byte-for-byte against the tag carried in the frame. The third builder attacks that stage directly.
Corrupted CMAC. ProtocolQa_BuildCmacFailureFrame() builds a valid frame — one whose tag is genuinely correct — and then flips bits in the final tag byte. The corrupting step is shown here.
out[*outLen - 1U] ^= 0x5AU;The last byte of a Wire v3 frame is the last byte of the 16-byte CMAC tag. XOR-ing it with 0x5A flips five bits, leaving a tag that is structurally the right size but cryptographically wrong. Nothing else about the frame changes: the header is consistent, the length is correct, and the ciphertext is untouched.
Because the frame is structurally valid, it passes every length check and reaches the CMAC verification stage. There, the tag the RX side computes over the frame will not match the corrupted tag in the frame, so authentication fails and the frame is rejected. This is the gate that a tampered or forged frame is meant to hit, and the corrupted-CMAC builder exists to confirm it does.
Reading the QA Output
It is important to be clear about what this harness is and is not. These QA tests are exploratory and were built in an ad-hoc manner. They do not implement a pass/fail framework: there is no assertion layer, no automated verdict, and no summary line that declares the run successful. The harness transmits hostile frames and logs what happens; interpreting that output is left to you.
Each test case carries an expected= string describing the RX outcome it is designed to produce. The TX side prints that string when it starts and queues each frame; the RX side prints the parser’s actual accept-or-reject result. Verifying a test means reading both log streams and confirming the RX outcome matches the expectation the TX side announced. For the three builders in this tutorial, the expected outcomes are:
truncated-frame→ rejected: below minimum Wire v3 frame lengthlength-mismatch→ rejected: declared payload length does not match rxLencmac-failure→ rejected: authentication tag mismatch
Beyond the specific reject reason, the RX contract from Part 3 still applies to all three: a rejected frame must be discarded without halting the receiver, must not corrupt replay state, and the receiver must go on to process the next frame normally. A future revision of the harness could turn these observations into a structured pass/fail framework, but as it stands the reader is the one making the call.
Project Downloads
The source files used in this tutorial are available for download. This package includes all custom code and documentation required to follow along with the project.
- Tutorial Source Files (Core and custom modules)
- STM32CubeIDE Configuration File (.ioc)
- Linker Scripts and Supporting Files
- README with setup instructions
- Separate Doxygen Documentation Download
Note: The source package does not include STM32Cube HAL drivers, middleware, or auto-generated system files. These are provided by STM32CubeIDE when creating a new project.
Documentation
This project includes full Doxygen-generated documentation for all custom source files, including the RadioLink protocol verification page that describes the complete QA harness.
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.
With the three builders in this tutorial now implemented, the QA harness in code matches the seven-case coverage the verification page documents. 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.


