cd ~

Published on 13TH APR 2026

STM32 HMAC-SHA256

Public

Minimal 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

hmac 0.13HMAC construction wrapping any Digest implementation
sha2 0.11SHA-256 digest, pure-Rust, no_std compatible
subtle 2Constant-time byte comparison (ct_eq) against timing side-channels
heapless 0.9Stack-allocated Vec with compile-time capacity bounds
defmt 1.0.1Efficient structured logging for embedded targets

Crate

cortex-m-rt 0.7Reset handler and binary entry point for Cortex-M
cortex-m 0.7.6Low-level Cortex-M intrinsics and peripheral access
embedded-hal 1.0Hardware abstraction traits for portable driver code
critical-section 1.0Portable critical section for atomic timestamp update
defmt-semihosting / rttLog transport: semihosting for QEMU, RTT for real hardware

Technologies & Learnings

Applied

Rust (no_std)Primary language; all code runs without a heap or standard library
Embedded toolchainCross-compilation, probe-rs flashing, cargo size profiling
QEMU embedded simulationUsed QEMU to simulate and test the Cortex-M7 target without physical hardware
heapless collectionsStack-allocated Vec for payload and frame buffers on RAM-constrained targets
defmt + RTT loggingStructured embedded logging with defmt and RTT transport for debug output

Newly learned

HMAC-SHA256First time implementing a cryptographic signing and verification scheme from first principles
MAVLink v2 protocolFirst time working with MAVLink: packet structure, CRC-extra, signing spec, and replay protection
STM32C092RC targetFirst time bringing up firmware on this specific Cortex-M0+ device
Constant-time cryptoFirst hands-on application of subtle::ConstantTimeEq to prevent timing side-channels

Project Info

Project typePersonal project
HardwareSTM32C092RC (Cortex-M0+), QEMU Cortex-M7 simulation
LanguageRust (no_std, no alloc)
RepositoryPublic (GitHub)