nRF52840 — Rust Embassy USB Honeypot Sentinel
The nRF52840 serves as a USB VBUS tamper sentinel and BLE mesh sensor. It monitors VBUS presence for unauthorized physical connections, broadcasts sensor data via BLE advertisements using the raw RADIO peripheral, and performs firmware attestation using SHA-256 + ECDSA P-256.
Role in the Architecture
Technical Specifications
| Feature | Detail |
|---|---|
| MCU | nRF52840 (ARM Cortex-M4F, 64 MHz) |
| RAM | 256 KB SRAM |
| Flash | 1 MB |
| Framework | Embassy 0.6 (async embedded Rust) |
| Language | Rust (#![no_std], #![no_main]) |
| Crypto | sha2 0.10 (SHA-256) + p256 0.13 (ECDSA P-256, RFC 6979) |
| Connectivity | BLE 5.0 (raw RADIO) + USB 2.0 (device mode) |
| Protocol | MQTT-SN v1.2 over embassy_sync::Channel → BLE transport |
| Key Storage | UICR Customer Registers (32 × 32-bit) |
| Heartbeat | 30 seconds |
Why Rust Embassy?
| Feature | Embassy | RTIC | Zephyr (C) | FreeRTOS (C) |
|---|---|---|---|---|
| Memory safety | Compile-time | Compile-time | Runtime checks | None |
| Async/await | Native | No | No | No |
| Zero-cost abstractions | Yes | Yes | No | No |
| No-alloc | Yes (#![no_std]) | Yes | Possible | Possible |
| Stack overflow | Impossible (no threads) | Possible | Possible | Possible |
Embassy uses Rust's async/await on bare metal — no RTOS, no threads, no heap. Tasks are state machines compiled to static memory, eliminating entire classes of embedded bugs.
Main Entry Point
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_nrf::{bind_interrupts, peripherals, usb};
bind_interrupts!(struct Irqs {
USBD => usb::InterruptHandler<peripherals::USBD>;
POWER_CLOCK => usb::vbus_detect::InterruptHandler;
});
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_nrf::init(Default::default());
// Connect shared MQTT-SN client to gateway.
{
let mut mqtt = mqtt_sn::MQTT.lock().await;
mqtt.connect("nrf_usb_sentinel_01").await;
}
// Spawn concurrent tasks (no threads — cooperative async)
spawner.spawn(usb_sentinel_task(p.USBD, p.POWER)).unwrap();
spawner.spawn(ble_advertise_task(p.RADIO)).unwrap();
spawner.spawn(attestation_task()).unwrap();
// Heartbeat loop
loop {
embassy_time::Timer::after(embassy_time::Duration::from_secs(30)).await;
}
}
USB Sentinel (VBUS Tamper Detection)
The USB sentinel monitors VBUS presence via the POWER peripheral's event registers. The nRF52840 operates in USB device mode — it cannot enumerate other USB devices. Instead, unexpected VBUS connections on a deployed sensor node signal physical tampering or probing attempts.
#[embassy_executor::task]
pub async fn usb_sentinel_task(
_usbd: peripherals::USBD,
_power: peripherals::POWER,
) {
// Pre-register MQTT-SN topic.
{
let mut mqtt = mqtt_sn::MQTT.lock().await;
mqtt.register_topic("aurora/sensors/nrf_usb_sentinel_01/alerts", 0x0030);
}
// Clear stale POWER events, then poll in 100 ms loop.
let mut prev_vbus = read_vbus_present();
loop {
// Check EVENTS_USBDETECTED / EVENTS_USBREMOVED / EVENTS_USBPWRRDY.
// On any state change → publish JSON alert via MQTT-SN:
// {"event":"connected","vbus_present":true,"output_ready":false,"alert_count":1}
// Belt-and-suspenders: also edge-detect on USBREGSTATUS register.
let vbus = read_vbus_present();
if vbus != prev_vbus { publish_alert(/* ... */).await; }
prev_vbus = vbus;
Timer::after(Duration::from_millis(100)).await;
}
}
Why USB VBUS Monitoring?
Physical access attacks bypass all network security controls. By deploying nRF52840 sentinels in server rooms with no expected USB activity, any VBUS event immediately alerts the SOC of physical intrusion attempts.
BLE Sensor Broadcasting
The BLE sensor reads the on-chip temperature sensor via direct TEMP register access, constructs an ADV_NONCONN_IND PDU, and transmits on all three BLE advertising channels (37, 38, 39) using the raw RADIO peripheral. Data is also published via MQTT-SN as a redundant delivery path.
#[derive(defmt::Format, Serialize)]
pub struct SensorData {
pub temperature_c: i16, // 0.1 °C units
pub motion_detected: bool,
pub tamper_status: u8, // 0=OK, 1=warning, 2=critical
pub battery_mv: u16, // millivolts
pub boot_count: u16,
}
#[embassy_executor::task]
pub async fn ble_advertise_task(_radio: peripherals::RADIO) {
loop {
let data = SensorData {
temperature_c: read_temperature(), // TEMP peripheral (0.25°C → 0.1°C)
motion_detected: false, // GPIO: PIR sensor
tamper_status: 0, // GPIO: tamper switch
battery_mv: 3300, // SAADC: VDDHDIV5
boot_count: boot,
};
// Build PDU: [S0=ADV_NONCONN_IND|TxAdd=random][LENGTH][AdvA(6), Flags(3), MfrData(13)]
let mut pdu = [0u8; 42];
let plen = build_adv_payload(&data, &mut pdu[2..]);
pdu[0] = 0x42; // ADV_NONCONN_IND | TxAdd=random
pdu[1] = plen as u8;
// Transmit on all 3 advertising channels via raw RADIO.
configure_radio_ble(pdu.as_ptr() as u32);
for &(ch, freq) in &ADV_CHANNELS {
transmit_on_channel(ch, freq);
}
// Redundant MQTT-SN publish (JSON via serde-json-core).
let mut json_buf = [0u8; 128];
if let Ok(len) = serde_json_core::to_slice(&data, &mut json_buf) {
mqtt.publish("aurora/sensors/.../telemetry", &json_buf[..len], QoS::AtMostOnce).await;
}
Timer::after(Duration::from_secs(10)).await;
}
}
BLE Advertisement Format
| Offset | Length | Field | Value |
|---|---|---|---|
| 0–5 | 6 | AdvA | FICR DEVICEADDR (random-static) |
| 6–8 | 3 | Flags AD | 0x02 0x01 0x06 (LE General Discoverable) |
| 9 | 1 | MfrData Length | 0x0C (12 bytes) |
| 10 | 1 | AD Type | 0xFF (Manufacturer-Specific) |
| 11–12 | 2 | Company ID | 0xFFFF (development) |
| 13 | 1 | Beacon Type | 0x01 (AuroraSOC sensor) |
| 14–15 | 2 | Temperature | BE, 0.1 °C units |
| 16 | 1 | Motion | 0/1 |
| 17 | 1 | Tamper | 0=OK, 1=warning, 2=critical |
| 18–19 | 2 | Battery | BE, millivolts |
| 20–21 | 2 | Boot Count | BE |
Raw RADIO Advertising
BLE advertising does not require a softdevice or BLE stack. The nRF52840 RADIO peripheral natively supports BLE 1 Mbit mode. The implementation configures the RADIO with the BLE advertising access address (0x8E89BED6), CRC polynomial, and data whitening, then transmits on each advertising channel sequentially.
Hardware Attestation
Attestation state is protected by an embassy_sync::Mutex (replacing the unsound static mut). The task computes a SHA-256 hash of the application firmware region in flash, reads the ECDSA P-256 private key from UICR customer registers, signs the attestation message, and publishes via MQTT-SN.
pub static ATTEST_STATE: Mutex<CriticalSectionRawMutex, AttestationState> =
Mutex::new(AttestationState {
boot_count: 0,
firmware_hash: [0u8; 32],
last_attestation_ok: false,
});
#[embassy_executor::task]
pub async fn attestation_task() {
// Increment boot count via Mutex (sound, no `static mut`).
{ ATTEST_STATE.lock().await.boot_count += 1; }
loop {
// 1. SHA-256 over flash 0x26000..0xF8000 (4 KiB chunks).
let fw_hash = compute_firmware_hash();
// 2. Read ECDSA P-256 key from UICR customer registers (32 bytes).
let key_bytes = read_signing_key_bytes();
let signing_key = SigningKey::from_slice(&key_bytes)?;
// 3. Build attestation message: device_id ‖ fw_hash ‖ boot_count.
let msg = [device_id, fw_hash, boot_count.to_le_bytes()].concat();
// 4. Sign with ECDSA P-256 (RFC 6979 deterministic nonce).
let sig: Signature = signing_key.sign(&msg);
// 5. Serialize to JSON via serde-json-core.
// {"device_id":"...","firmware_hash":"...","boot_count":N,"signature":"..."}
let report = AttestationReport { device_id, firmware_hash, boot_count, signature };
let len = serde_json_core::to_slice(&report, &mut json_buf)?;
// 6. Publish via MQTT-SN (QoS 1).
mqtt.publish("aurora/attestation/.../response", &json_buf[..len], QoS::AtLeastOnce).await;
Timer::after(Duration::from_secs(300)).await; // Every 5 minutes
}
}
Cryptography
| Operation | Crate | Performance (Cortex-M4F @ 64 MHz) |
|---|---|---|
| SHA-256 (860 KB firmware) | sha2 0.10 (no_std) | ~200 ms |
| ECDSA P-256 Sign | p256 0.13 (RFC 6979) | ~500 ms |
| JSON encode | serde-json-core 0.6 | < 1 ms |
The p256 crate uses constant-time arithmetic and deterministic nonces (RFC 6979), making it suitable for embedded use without an external RNG. The ECDSA private key is provisioned into UICR customer registers during manufacturing.
MQTT-SN Protocol
The MQTT-SN client implements OASIS MQTT-SN v1.2 packet construction. A shared MqttSnClient instance behind an embassy_sync::Mutex is accessible from all tasks. Outbound frames are pushed to a static Channel that the BLE transport layer drains.
/// Shared MQTT-SN client — all tasks access via Mutex.
pub static MQTT: Mutex<CriticalSectionRawMutex, MqttSnClient> =
Mutex::new(MqttSnClient::new([10, 0, 0, 1], 1883));
/// Outbound frame channel — BLE transport drains this.
pub static TX_CHANNEL: Channel<CriticalSectionRawMutex, Vec<u8, 128>, 8> =
Channel::new();
pub struct MqttSnClient {
gateway_addr: [u8; 4],
gateway_port: u16,
connected: bool,
msg_id: u16,
topics: [TopicEntry; 8], // FNV-1a hash → topic_id lookup
topic_count: usize,
}
impl MqttSnClient {
/// Build CONNECT frame: Length + 0x04 + Flags(CleanSession) + ProtocolID + Duration + ClientId
pub async fn connect(&mut self, client_id: &str) { /* ... */ }
/// Build PUBLISH frame with pre-registered topic IDs.
pub async fn publish(&mut self, topic: &str, payload: &[u8], qos: QoS) { /* ... */ }
/// Build SUBSCRIBE frame.
pub async fn subscribe(&mut self, topic: &str, qos: QoS) { /* ... */ }
/// Build DISCONNECT frame.
pub async fn disconnect(&mut self) { /* ... */ }
}
Pre-Registered Topics
| Topic ID | Topic Path | Publisher |
|---|---|---|
0x0010 | aurora/attestation/nrf_usb_sentinel_01/response | Attestation |
0x0020 | aurora/sensors/nrf_usb_sentinel_01/telemetry | BLE Sensor |
0x0030 | aurora/sensors/nrf_usb_sentinel_01/alerts | USB Sentinel |
Why MQTT-SN over regular MQTT?
| Feature | MQTT-SN | MQTT |
|---|---|---|
| Header size | 2 bytes | 2+ bytes |
| Topic handling | Pre-registered IDs (2 bytes) | Full string each time |
| Transport | Any (UDP, BLE, Zigbee) | TCP only |
| Sleep support | Built-in (device can sleep) | Keep-alive required |
| Gateway | Required (ESP32-S3) | Direct to broker |
For a device with 256KB RAM on a coin cell battery, every byte matters.
Memory Layout
Flash (1MB):
├── Bootloader (MCUboot) 16KB
├── Application 512KB
├── OTA staging 512KB (A/B update)
└── UICR 4KB (keys, config)
SRAM (256KB):
├── Stack 8KB
├── Static data 32KB
├── Embassy executor ~4KB
├── BLE stack ~32KB
├── MQTT-SN buffers ~2KB
└── Available ~178KB
Build and Flash
# Build with Embassy
cargo build --release --target thumbv7em-none-eabihf
# Flash via probe-rs
probe-rs run --chip nRF52840_xxAA target/thumbv7em-none-eabihf/release/aurora-nrf52
# Debug
probe-rs attach --chip nRF52840_xxAA