Files
swaits 975713636b docs(audit): add Mythos security audit and harden esp-println on UART boards
Audit conducted at v1.5.2 (commit 688938d). Findings:
- 0 Critical / 0 High / 0 Medium / 2 Low / 1 Info
- Realistic attack surface = host transport + OTA RX; both traced
  end-to-end with no exploitable defects found.

Hardening applied as part of audit follow-up:
- esp-println now uses default-features=false; output target is
  selected per-board. UART-host boards (heltec_v3_uart,
  elecrow_thinknode_m2, lilygo_tbeam classic) get `no-op` so
  defmt/panic output cannot corrupt frames on UART0 (the host
  protocol wire). Native-USB boards keep `auto` so espflash-monitor
  still works.
- README documents the trust model: no host-transport auth, no
  RF-regulatory enforcement, MAC exposed via USB iSerialNumber +
  GET_INFO.mcu_uid (intentional, needed by donglora-mux for
  multi-dongle disambiguation).
2026-05-13 13:19:59 -06:00

116 lines
8.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 1 — Reconnaissance
## Project
`donglora` v1.5.2 — Embedded Rust firmware. Single binary per board, compiled with mutually-exclusive cargo features. Exposes the LoRa radio over a host transport (USB CDC-ACM or UART) using a COBS-framed binary protocol (DongLoRa Protocol v2).
## Languages / runtime
- **Rust 1.94 stable** for ARM (thumbv6m, thumbv7em); **Rust esp** fork for Xtensa (esp32, esp32-s3). `no_std`, `no_main`, `panic = abort`, LTO fat, opt-level 3.
- **No allocator.** All collections are fixed-capacity `heapless::Vec<_, N>`.
- **Async runtime: embassy-executor 0.10** (Cortex-M) / **esp-rtos 0.3** (Xtensa). Three long-lived tasks per board.
- **Statecharts: hsmc 0.6** drive the host, radio, and display tasks.
## Supported boards (one feature → one binary)
| Board | MCU | Radio | Host transport |
|-----------------------------|------------|--------|------------------------|
| heltec_mesh_node_t114 | nRF52840 | SX1262 | USB CDC-ACM (native) |
| heltec_v3 | ESP32-S3 | SX1262 | USB CDC-ACM (native) |
| heltec_v3_uart | ESP32-S3 | SX1262 | UART0 via CP2102 |
| heltec_v4 | ESP32-S3 | SX1262 | USB CDC-ACM (native) |
| elecrow_thinknode_m2 | ESP32-S3 | SX1262 | UART0 via CP2102 |
| lilygo_tbeam | ESP32 | SX1276 | UART0 via CP2102 |
| lilygo_tbeam_supreme | ESP32-S3 | SX1262 | USB CDC-ACM (native) |
| rak_wisblock_4631 | nRF52840 | SX1262 | USB CDC-ACM (native) |
| waveshare_rp2040_lora | RP2040 | SX1262 | USB CDC-ACM (native) |
| wio_tracker_l1 | nRF52840 | SX1262 | USB CDC-ACM (native) |
## Entry points (untrusted-byte ingress)
There is **exactly one network-facing entry point per board image**: the host transport (USB CDC-ACM **or** UART, never both in the same build).
| Module | Inbound bytes from |
|------------------------------|-------------------------------------|
| `src/host/usb.rs` | USB CDC-ACM (host OS, kernel-side) |
| `src/host/uart.rs` | UART0 (CP2102 → host OS) |
| `src/radio.rs` (RX path) | LoRa over-the-air (any peer in RF range, on configured freq+sync) |
There is **no networking** (TCP/UDP/HTTP/etc.), **no filesystem**, **no shell**, **no storage**. The radio is the only RF surface; OTA RX packets become a `DeviceMessage::Rx(...)` forwarded to the host with `tag = 0` — the firmware itself does not interpret payload bytes.
USB descriptors / VID:PID `1209:5741` (composite IAD device). The CDC-ACM serial-number string is the device's MAC address as 12-char uppercase hex (`src/host/usb.rs:184-189`).
## Wire format (one path from byte → command)
```
raw bytes
→ FrameDecoder (donglora-protocol 1.2.0 frame.rs)
COBS-delimited, CRC16/CCITT-FALSE-protected, payload ≤ MAX_PAYLOAD_FIELD (275 B)
→ dispatch_frame (host/framing.rs)
rejects tag=0 with async ERR(EFRAME)
calls Command::parse(type_id, payload)
→ InboundEvent::Command { tag, cmd } via CommandChannel (depth 8)
→ radio task statechart (radio.rs)
```
Egress (`DeviceMessage::Ok|Err|Rx|TxDone`) is symmetric: built by the radio task, sent through `OutboundChannel` (depth 64), encoded by `encode_outbound`, written packet-by-packet.
Commands accepted from host: `PING`, `GET_INFO`, `SET_CONFIG`, `TX`, `RX_START`, `RX_STOP` (only these `type_id` bytes; anything else → `ERR(EUNKNOWN_CMD)`).
## Trust boundaries
| Boundary | Direction | Treated as |
|---------------------------------------|-----------------|------------------|
| USB / UART host → firmware | inbound bytes | **trusted** (it's the user's PC) |
| LoRa RF peer → firmware (RX payload) | inbound bytes | **untrusted**, but only memcpy'd to host — firmware never interprets |
| firmware → SX1262/SX1276 via SPI | outbound | trusted (own peripheral) |
The firmware is intentionally a **transparent radio bridge**: there is no auth, no crypto, no session state, no key material on device. The radio's freq, power, sync_word, etc. are whatever the host sets (within hardware limits enforced by `validate_lora`).
## Auth / session layer
**None.** Anything that can write CDC-ACM bytes to the device can drive the radio. Per design — the device is a "dongle," ownership is physical-attachment.
The protocol's inactivity watchdog (`PROTOCOL.md §3.4`, 1 s) drops the radio to `Unconfigured` if the host stops sending bytes — this is liveness/cleanup, **not** auth. USB-side this is gated on DTR (USB CDC line state); UART has no DTR so it relies purely on the watchdog.
## Data at rest
**None of security interest.**
- Static `Info` blob built once at boot — MAC, fw version, capability bitmaps. Visible to anyone who issues `GET_INFO`.
- No credentials, tokens, keys, PII.
- Persistent storage: only board-specific bootloader settings region; **firmware never writes to flash at runtime** (see `ld/*.x` — app region is read-only post-flash).
- nRF52840 boards' `s140 SoftDevice` region (0x01000–0x26000) preserved at flash time; firmware never touches it.
## Dependencies (Cargo.lock, security-relevant subset)
- `donglora-protocol 1.2.0` — own crate, the wire codec (frame, command, modulation, info, events). Fuzz/mutation-tested in sibling repo (`/home/swaits/Code/donglora-org/protocol/`).
- `ucobs 0.3` — COBS encode/decode, no_std, very small.
- `lora-phy` — git fork from `swaits/lora-rs branch=fixed` (carries CAD IRQ-clear fixes pending upstream PRs #428/#431/#432). All SX126x/SX127x SPI primitives.
- `hsmc 0.6` — own crate (statecharts).
- `embassy-* 0.5/0.6/0.10/0.8` — well-known embedded async runtime.
- `defmt 0.3`, `panic-probe 1.0` (Cortex-M boards) / `esp-backtrace 0.19` + `esp-println 0.17` (Xtensa boards).
- `heapless 0.9` — fixed-capacity collections.
No HTTP, JSON, TLS, SQL, template, regex, deserialization-of-user-data dependencies.
## Deployment context
Firmware is flashed via UF2 (Cortex-M) or `espflash` (Xtensa). No OTA update path on the device itself — re-flashing requires physical USB. Production builds run with `DEFMT_LOG` unset → defmt strips info/debug/warn at compile time; only `error!` survives.
## Existing security controls / hardening already in place
- **`#![cfg_attr(not(test), no_std)]` / `panic = abort`** — no panic unwinding to abuse.
- **`#![deny(clippy::undocumented_unsafe_blocks)]`** in Cargo.toml — every `unsafe` block must justify itself.
- **`unsafe` audit** (4 sites total):
- `main.rs:105` — one-time `static mut INFO` init at boot, before any task spawns.
- `hal/nrf52840.rs:102, 140, 151` — `read_volatile` / `write_volatile` on MMIO registers (POWER.RESETREAS, FICR.DEVICEADDR). Standard.
- `hal/rp2040.rs:68, 79` — one-time `static mut MAC_REF` init at boot.
- **Memory layout (`ld/nrf52840-memory.x`)** preserves the factory SoftDevice region; UF2-only flashing prevents accidental overwrite.
- **CRC-16/CCITT-FALSE** on every frame, COBS framing — corrupt bytes are caught by either layer before reaching `Command::parse`.
- **Strict typed parsing**: every enum field rejects out-of-range bytes (`from_u8` returns `Option` and `InvalidField` is propagated); reserved bits in `TxFlags` rejected; reserved byte in `LrFhssConfig` rejected.
- **`validate_lora` (`radio.rs:2125-2145`)**: freq within board's chip-supported range, SF ∈ {5..=12} **and** in board bitmap, BW in board bitmap, TX power within board range, preamble ≥ 6.
- **Hardware-limit clamping**: `MAX_OTA_PAYLOAD = 255` matches the chip's `payload_len` register width; the receive buffer is sized exactly to it.
- **`RADIO_THROTTLED` backpressure flag** prevents queue floods if the host is unresponsive.
- **State-machine cancel-safety**: cancel-unsafe lora-phy futures (`lora.tx`, `lora.cad`, `LoRa::new`, `lora.init`) are hosted in chart states whose only event handlers are emitted by the future itself on natural completion — chart cannot drop them.
- **Reset-and-recovery state machine** with bounded attempts (`MAX_RECOVERY_ATTEMPTS = 4`) and a terminal `Dead` state that still answers `PING`/`GET_INFO`.