Published on 13TH APR 2026
STM32 HMAC-SHA256
PublicMinimal no_std Rust implementation that signs and verifies MAVLink v2 messages using HMAC-SHA256, targeting a QEMU-simulated Cortex-M7 and a real STM32C092RC board.
Overview
MAVLink v2 defines an optional packet-signing mechanism used in drone and robotics telemetry. The signature appended to each frame is the first 6 bytes of HMAC-SHA256(secret_key, signing_bytes), where signing_bytes is the concatenation of the MAVLink header, payload, CRC, link-id, and a 48-bit timestamp. This project implements that mechanism in pure Rust with no heap allocation, targeting both a QEMU-simulated Cortex-M7 and a real STM32C092RC microcontroller.
- sign_frame: computes CRC, sets the SIGNED_FLAG, appends the truncated 6-byte HMAC tag
- verify_frame: re-derives the HMAC, compares it in constant time, enforces replay protection
- Everything runs without a heap (no_std, no alloc): all buffers are stack-allocated via heapless::Vec
- 17-test host-side suite covers all sign/verify paths without requiring QEMU or hardware
Repository Structure
The workspace is split into two layers. mavlink/ is a standalone no_std library containing the full sign and verify implementation with no MCU dependency. chips/ holds one crate per hardware target, each an independent binary that imports mavlink and can be flashed or simulated independently. Adding a new microcontroller means adding a new crate under chips/ without modifying the core library.
- mavlink/: no_std library containing sign_frame, verify_frame, CRC, HMAC, and replay state
- chips/qemu-m7: QEMU Cortex-M7 binary using defmt-semihosting for log output
- chips/stm32c092rc: STM32C092RC binary using defmt-rtt and probe-rs for flashing
- New MCU support requires only a new chips/ crate with zero changes to the core library
sign_frame and verify_frame
The two public functions of the mavlink library cover the full MAVLink v2 signing lifecycle.
sign_frame
Takes a mutable frame buffer and a secret key. Computes the X.25 CRC over the header and payload (including the message-type-specific crc_extra byte), sets INC_FLAGS = 0x01 to mark the frame as signed, then feeds header, payload, CRC, link-id, and timestamp into the HMAC state in wire order without assembling a scratch buffer. Writes the first 6 bytes of the resulting digest into the signature field of the frame.
- Computes X.25 CRC with crc_extra lookup per message ID
- Sets SIGNED_FLAG (INC_FLAGS = 0x01) before computing the tag
- Streaming HMAC: mac.update() called in wire order with no 280-byte scratch buffer needed
- Truncates the 32-byte SHA-256 digest to 6 bytes per MAVLink v2 spec
verify_frame
Re-derives the HMAC from the received frame and compares it against the stored 6-byte tag using constant-time equality (subtle::ConstantTimeEq) to prevent timing side-channels. After the HMAC check passes, enforces two independent replay guards before updating the global MavLinkState.
- Re-derives HMAC with the same streaming approach as sign_frame
- Constant-time comparison via subtle::ConstantTimeEq with no short-circuit leakage
- Window check: rejects frames whose timestamp is more than 10 seconds in the past (1,000,000 MAVLink units)
- Monotonic check: inside a critical_section, rejects any frame not strictly newer than the last accepted timestamp
- Updates MavLinkState atomically on success
Design Decisions
Four decisions shaped the implementation to be safe, minimal, and correct on targets with as little as 30 KB of RAM.
No heap, no allocator
heapless::Vec<u8, N> is used for the payload (MAX_PAYLOAD_SIZE = 255) and the serialised frame (MAX_FRAME_SIZE = 280). Both live on the stack with a fixed upper bound known at compile time. This makes the implementation safe on severely RAM-constrained targets without requiring a global allocator.
Streaming HMAC
feed_signing_bytes calls mac.update() in wire order (header, payload, CRC, link-id, timestamp) without first assembling the full frame into a temporary buffer. HMAC processes input incrementally, so this is semantically identical to a single large update call but avoids allocating a 280-byte scratch buffer on already-tight stacks.
Constant-time comparison
subtle::ConstantTimeEq is used to compare the computed and received HMAC tags. A naive == or memcmp would short-circuit on the first differing byte, leaking timing information about how many bytes of a forged tag are correct, which is a classic side-channel. subtle prevents this at zero additional memory cost.
Replay protection
verify_frame enforces two independent guards. A window check rejects frames whose timestamp is more than 1,000,000 MAVLink units (10 seconds) in the past. A monotonic check, executed inside a critical_section, rejects any frame whose timestamp is not strictly greater than the last accepted one and updates MavLinkState atomically. Together they prevent both delayed delivery and replay of recently valid frames within the window.
Hardware Targets
The same core library binary is used by both targets. Only the chip crate and log transport differ.
QEMU (Cortex-M7, simulated)
Runs on the MPS2-AN500 machine model with a Cortex-M7 CPU. Log output goes through semihosting so no physical hardware is needed. Target triple is thumbv7em-none-eabihf.
STM32C092RC (real hardware)
Tested on the STM32C092RC (Cortex-M0+, 256 KB flash, 30 KB RAM), flashed via probe-rs. Log output uses defmt-rtt over the debug probe. In release mode the RTT ring buffer and defmt are stripped to bring flash usage down to 15 KB.
Testing
The mavlink library has a host-side test suite with 17 tests that run without QEMU or hardware. They cover successful sign-and-verify, wrong key, unknown message IDs, payload and signature tampering, CRC mismatch, timestamp ordering, and replay prevention. All tests run on the host target triple.
Libraries Used
Crate
Crate
Technologies & Learnings
Applied
Newly learned