3 Commits
Author SHA1 Message Date
swaits 7da10540d1 chore: repoint remaining links at git.swaits.com
Rewrites lingering github.com / *.sr.ht references to this Gitea instance,
translating GitHub and Sourcehut URL shapes to Gitea's (/blob/<ref>/ and
sr.ht /tree/<ref>/item/ -> /src/branch/<ref>/, /tree/<tag> -> /src/tag/<tag>,
/actions/workflows/X -> /actions?workflow=X, /edit/ -> /_edit/).

Left pointing at the old hosts on purpose: pull-request links (PRs were not
migrated), forks that were never migrated, and GitHub-only surfaces Gitea
lacks. OpenPGP identity proofs are untouched by design.
2026-08-31 18:32:07 -06:00
swaits cf2ec92c4a chore: point repository at git.swaits.com 2026-08-31 17:19:33 -06:00
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
7 changed files with 632 additions and 6 deletions

No files matched your search

+1 -1
View File
@@ -552,7 +552,7 @@ supported board.
### Infrastructure ### Infrastructure
- Cargo.toml `repository` + `homepage` now point at the canonical - Cargo.toml `repository` + `homepage` now point at the canonical
`https://github.com/donglora/firmware` (the `swaits/donglora` `https://git.swaits.com/donglora/firmware` (the `swaits/donglora`
repo is archived). repo is archived).
## [0.5.0] - 2026-04-16 ## [0.5.0] - 2026-04-16
+31 -3
View File
@@ -9,8 +9,8 @@ build = "build.rs"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT" license = "MIT"
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host" description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
repository = "https://github.com/donglora/firmware" repository = "https://git.swaits.com/donglora/firmware"
homepage = "https://github.com/donglora/firmware" homepage = "https://git.swaits.com/donglora/firmware"
keywords = ["lora", "radio", "embedded", "usb", "firmware"] keywords = ["lora", "radio", "embedded", "usb", "firmware"]
categories = ["embedded", "hardware-support", "no-std"] categories = ["embedded", "hardware-support", "no-std"]
@@ -70,7 +70,16 @@ esp-backtrace = { version = "0.19", optional = true, features = [
esp-hal = { version = "1.1", optional = true, features = [ esp-hal = { version = "1.1", optional = true, features = [
"unstable", "unstable",
] } ] }
esp-println = { version = "0.17", optional = true, features = [ # `default-features = false` strips `auto` + `colors`. The output target
# (jtag-serial / uart / no-op / auto) is selected explicitly per board
# feature below. UART-host boards (heltec_v3_uart, elecrow_thinknode_m2,
# lilygo_tbeam) MUST use `no-op` — esp-println's `auto` falls back to
# UART0 on every ESP target here (no USB-Serial-JTAG host is ever
# attached to those boards), and UART0 IS the host protocol wire, so
# any defmt::error!() or panic backtrace would corrupt frames mid-flight.
# Native-USB boards keep `auto` so espflash-monitor still works in dev.
esp-println = { version = "0.17", optional = true, default-features = false, features = [
"critical-section",
"defmt-espflash", "defmt-espflash",
] } ] }
esp-rtos = { version = "0.3", optional = true, features = [ esp-rtos = { version = "0.3", optional = true, features = [
@@ -171,6 +180,11 @@ heltec_v3 = [
"esp-bootloader-esp-idf/esp32s3", "esp-bootloader-esp-idf/esp32s3",
"esp-hal/esp32s3", "esp-hal/esp32s3",
"esp-println/esp32s3", "esp-println/esp32s3",
# Native USB CDC-ACM via OTG_FS — UART0 (GPIO43/44) is dangling, so
# `auto`'s fallback target writes into the void: invisible but legal.
# Keeps espflash-monitor working in dev when USB-Serial-JTAG ROM mode
# is active before the firmware switches to OTG_FS.
"esp-println/auto",
"esp-rtos/esp32s3", "esp-rtos/esp32s3",
] ]
heltec_v3_uart = [ heltec_v3_uart = [
@@ -185,6 +199,11 @@ heltec_v3_uart = [
"esp-bootloader-esp-idf/esp32s3", "esp-bootloader-esp-idf/esp32s3",
"esp-hal/esp32s3", "esp-hal/esp32s3",
"esp-println/esp32s3", "esp-println/esp32s3",
# UART0 IS the host protocol wire on this board. `no-op` discards all
# esp-println / defmt output so error-level logs or panic backtraces
# cannot corrupt frames mid-flight. Use `debug-checkpoint` for
# development diagnostics (LED-blink pattern instead of log bytes).
"esp-println/no-op",
"esp-rtos/esp32s3", "esp-rtos/esp32s3",
] ]
heltec_v4 = [ heltec_v4 = [
@@ -199,6 +218,8 @@ heltec_v4 = [
"esp-bootloader-esp-idf/esp32s3", "esp-bootloader-esp-idf/esp32s3",
"esp-hal/esp32s3", "esp-hal/esp32s3",
"esp-println/esp32s3", "esp-println/esp32s3",
# Native USB CDC-ACM — same rationale as heltec_v3.
"esp-println/auto",
"esp-rtos/esp32s3", "esp-rtos/esp32s3",
] ]
elecrow_thinknode_m2 = [ elecrow_thinknode_m2 = [
@@ -213,6 +234,8 @@ elecrow_thinknode_m2 = [
"esp-bootloader-esp-idf/esp32s3", "esp-bootloader-esp-idf/esp32s3",
"esp-hal/esp32s3", "esp-hal/esp32s3",
"esp-println/esp32s3", "esp-println/esp32s3",
# UART0 IS the host protocol wire — see heltec_v3_uart rationale.
"esp-println/no-op",
"esp-rtos/esp32s3", "esp-rtos/esp32s3",
] ]
lilygo_tbeam_supreme = [ lilygo_tbeam_supreme = [
@@ -227,6 +250,8 @@ lilygo_tbeam_supreme = [
"esp-bootloader-esp-idf/esp32s3", "esp-bootloader-esp-idf/esp32s3",
"esp-hal/esp32s3", "esp-hal/esp32s3",
"esp-println/esp32s3", "esp-println/esp32s3",
# Native USB CDC-ACM — same rationale as heltec_v3.
"esp-println/auto",
"esp-rtos/esp32s3", "esp-rtos/esp32s3",
] ]
lilygo_tbeam = [ lilygo_tbeam = [
@@ -241,6 +266,9 @@ lilygo_tbeam = [
"esp-bootloader-esp-idf/esp32", "esp-bootloader-esp-idf/esp32",
"esp-hal/esp32", "esp-hal/esp32",
"esp-println/esp32", "esp-println/esp32",
# ESP32 classic has no USB-Serial-JTAG peripheral — `auto` would
# unconditionally route to UART0 = host protocol wire. Use `no-op`.
"esp-println/no-op",
"esp-rtos/esp32", "esp-rtos/esp32",
] ]
rak_wisblock_4631 = [ rak_wisblock_4631 = [
+29 -2
View File
@@ -63,8 +63,35 @@ See [src/board/PORTING.md](src/board/PORTING.md).
## Protocol ## Protocol
See [PROTOCOL.md](https://github.com/donglora/protocol-rs/blob/main/PROTOCOL.md) See [PROTOCOL.md](https://git.swaits.com/donglora/protocol-rs/src/branch/main/PROTOCOL.md)
in the [donglora-protocol](https://github.com/donglora/protocol-rs) repo in the [donglora-protocol](https://git.swaits.com/donglora/protocol-rs) repo
(crates.io: [`donglora-protocol`](https://crates.io/crates/donglora-protocol)) (crates.io: [`donglora-protocol`](https://crates.io/crates/donglora-protocol))
for the complete wire format specification — that is the canonical home; for the complete wire format specification — that is the canonical home;
this firmware just consumes it. this firmware just consumes it.
## Trust model and identifiers
DongLoRa is a **transparent LoRa bridge**, not a security boundary:
- **No authentication on the host transport.** Anything that can write
to the CDC-ACM endpoint or UART pins drives the radio. Physical USB
attachment is the trust boundary.
- **No RF-regulatory enforcement.** Frequency, sync word, and TX power
are whatever the host sends, within the chip's hardware range.
Regional compliance (FCC / CE / ETSI / duty cycle) is the user's
responsibility, not the firmware's.
- **Device identifier exposed without authentication.** The board's
6-byte MAC (factory-burned, permanent) is published in two places
readable by anyone who can enumerate or open the device:
1. The USB CDC-ACM `iSerialNumber` string descriptor (12 hex
chars, uppercase). Visible to any OS / userspace process that
can read USB device descriptors.
2. The `GET_INFO.mcu_uid` field on the wire, available to any
host that can issue protocol frames.
This is intentional — host-side tools (`donglora-mux`,
`client-py`, etc.) rely on it to distinguish multiple dongles on
one host. The privacy trade-off: a user reusing the same dongle
across different hosts/networks is correlatable by that MAC across
contexts. If that matters in your threat model, isolate the dongle
per context or run it behind a trusted multiplexer.
+115
View File
@@ -0,0 +1,115 @@
# 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`.
+94
View File
@@ -0,0 +1,94 @@
# Phase 2 — Threat model
## Actors
| Actor | Capabilities | Reach |
|--------------------------------------|---------------------------------------------------------------------|--------------------|
| **Local USB attacker (LUSB)** | Read/write raw bytes to CDC-ACM endpoint or UART pins; read USB descriptors. Implies kernel-level access on the host *or* physical hardware access. | high impact, but presumes the host is already compromised |
| **OTA peer (OTA)** | Transmit arbitrary LoRa packets within RF range, on the configured frequency / sync word / SF / BW. Cannot read or affect host transport directly. | low impact (firmware does not interpret), broad reach |
| **Hostile host application (APP)** | Subset of LUSB: a malicious userspace program on the host PC that opens the CDC-ACM device — possibly without the user's intent. Limited to whatever the OS permits on that device node. | depends on host's permissions on the tty/dev node |
| **Build-time adversary (BUILD)** | Compromised crate in the dependency graph or compromised local toolchain. | out of scope of runtime audit; noted only for completeness |
## Entry points and worst-case impact, in attacker-reachability order
### Entry point #1 — Host transport (USB CDC-ACM / UART)
**Reachable by:** LUSB, APP.
**What they can influence:** every byte of every frame after `0x00` sentinels. Specifically: `type_id` (1 B), `tag` (2 B LE), `payload` (≤ 275 B), `crc` (2 B). After CRC + COBS + parser checks, controlled fields are:
- `type_id` ∈ {1..=6} (validated by `Command::parse`)
- `tag` ∈ 1..=65535 (tag=0 rejected by framing layer)
- For `SET_CONFIG`: every byte of `LoRaConfig` (15 B) — freq, SF, BW, CR, preamble_len, sync_word, tx_power, header_mode, payload_crc, iq_invert. The other three modulations parse but the firmware rejects with `EMODULATION` after parsing.
- For `TX`: `TxFlags` byte (bit 0 only — reserved bits rejected) + 1..=255 bytes of OTA payload.
**Worst-case if attacker fully controls input:**
The firmware is a **transparent bridge by design**. Any attacker who can write to the host transport can:
- Tune the radio to any frequency in the chip's range (e.g. 137 MHz – 1020 MHz for SX127x; 150–960 MHz for SX126x).
- Set TX power up to the board's `TX_POWER_RANGE.max` (typically +22 dBm).
- Transmit arbitrary packet bytes with arbitrary sync word at arbitrary spreading factor.
- Receive on arbitrary parameters.
- Read the device's MAC address (via `GET_INFO` and/or USB serial descriptor).
This is **the intentional product feature**. The firmware does not enforce regional frequency rules, duty cycle, listen-before-talk policy beyond the SF-tuned CAD, or any auth: the host owns the radio. Treating "host can drive the radio" as a vulnerability would be a category error.
The remaining attacker goals against the firmware itself are:
- **DoS** — crash, hang, reboot, or wedge the firmware so the user has to power-cycle.
- **Memory corruption / RCE** — overflow a buffer, hit an OOB write, leak ASLR-less code-pointer.
- **Cross-device escalation** — pivot from the radio task into the host PC (e.g. malicious USB descriptors / report).
- **Information leak** — exfiltrate cross-tenant data. But there is no cross-tenant data on this device. Skip.
### Entry point #2 — Over-the-air RX
**Reachable by:** OTA. Anyone with a LoRa radio that can transmit on the configured (`freq`, `sf`, `bw`, `sync_word`, `cr`, `preamble_len`, `iq_invert`) tuple while the firmware is in `RxActive`.
**What they can influence:** the bytes of a single received LoRa packet — `0..=MAX_OTA_PAYLOAD` (255) bytes of body. Side-channel metadata that the firmware *generates* (not the attacker): RSSI (signed i16), SNR (signed i16), timestamp (u64 micros). The header_mode / CRC flags are configured by the host, not the OTA peer.
**What this eventually does:** `collect_rx_packet` (`radio.rs:1664-1702`) calls `lora.get_rx_result(pkt, &mut ctx.rx_buf)`. Length is clamped to `MAX_OTA_PAYLOAD` before `extend_from_slice` into a fresh `heapless::Vec<u8, 255>`. The vec becomes the body of a `DeviceMessage::Rx`. Wire encoding (`events.rs:228-249`) is a fixed-layout memcpy, no parsing of OTA bytes inside the firmware.
**Worst-case if attacker fully controls the OTA packet:**
- The firmware does not interpret OTA payload bytes; they are an opaque buffer relayed verbatim to the host. Worst is a memory-corruption bug in `get_rx_result` (lora-phy crate) or in `extend_from_slice` (heapless), which would be a third-party bug not firmware logic.
- Spam packets at the configured peer ID to flood `OutboundChannel` (depth 64). Throttled by `RADIO_THROTTLED` once host stalls; otherwise the host sees a burst. Standard RF DoS — there is no defense at the device level beyond "host stops listening."
- Trigger `IrqWaitFailed` → `RxResuming` retry loop. After `MAX_RESUME_RETRIES = 5` falls back to `Idle`. Recovers cleanly.
OTA is a **broad reach but very narrow impact** surface: the firmware exposes essentially no interpretation surface to the OTA peer.
### Entry point #3 — USB descriptor handling (control transfers)
**Reachable by:** LUSB, APP (anyone enumerating the device).
**What they can influence:** USB control transfer request type / index / length. Handled inside `embassy_usb::Builder::run()` — out of firmware's code.
**Worst-case:** a malformed control transfer crashes the USB stack and the firmware. The `CTRL_BUF` size was bumped to 128 specifically because 64 was crashing on some Linux enumeration paths (see comment in `host/usb.rs:191-196`). The user reports modem-manager's `SET_INTERFACE` with out-of-range alt setting is normal and embassy_usb's STALL is correct. **Suspected** dependency-level risk that we accept as a third-party concern.
## Code paths that warrant deep audit (prioritized)
In rough descending order of attacker-reachability × impact:
1. **Frame decode → command dispatch (`host/framing.rs`, `host/usb.rs::dispatch_bytes`, `host/uart.rs::dispatch_bytes`, `donglora-protocol/frame.rs`, `commands.rs`, `modulation.rs`):** every reachable byte from LUSB flows through here. Look for panics, buffer overruns in fixed-capacity scratch buffers, ambiguous overflow / CRC-skip paths.
2. **`Command::parse` for `SET_CONFIG`:** the deepest parse path — modulation-id dispatch, then nested decode. `LoRaConfig::decode` and friends.
3. **`apply_set_config` + `validate_lora` (`radio.rs:1233-1309`, `2125-2145`):** post-parse validation. Where do unchecked fields go — sync_word, iq_invert, header_mode, payload_crc? Memory note flags sync_word low-nibble validation as intentionally absent.
4. **`Command::Tx` ingestion → on-air TX (`radio.rs` chart `TxSession`):** TX payload (1..=255 attacker bytes) reaches the SX1262 PA without further parsing. The body bytes never act on firmware state except as opaque memcpy. Audit for buffer-length confusion between the chart's `pending_tx.data` and lora-phy's `prepare_for_tx(.., &pt.data)`.
5. **`get_rx_result` → `record_packet` → `OutboundFrame` encode (`radio.rs:1664-1715`, `2008-2040`; `events.rs:228-289`):** the only path where OTA-attacker bytes are ingested. Audit length handling and RxPayload encoding's bounds.
6. **State machine reachability**: chart panics (`tag_or_panic`, `NonZeroU16::new(...).expect(...)`, `radio chart returned`) — are any reachable from wire input?
7. **`unsafe` blocks** (4 sites): are they sound under the stated preconditions?
8. **USB serial number / Info disclosure** — what does the device disclose without prior auth?
9. **Dependencies**: any reachable CVE-bearing dep? Check `donglora-protocol`, `ucobs`, `lora-phy` (fork), `hsmc`, `heapless`, `embassy-usb`, `embassy-nrf` / `embassy-rp` / `esp-hal`.
10. **Build-time (`build.rs`)**: code generation reads files from `src/board/`. Build-time only; out of runtime attacker scope.
## Out-of-scope explicitly
- The host-side application that consumes the protocol (out of repo: ai-bot, bridge, client-py, mux-rs).
- The Anthropic / Claude scripting context.
- The donglora-protocol crate's *fuzz-targeted* invariants — they are validated in the sibling repo (`/home/swaits/Code/donglora-org/protocol/fuzz/`); we audit only how the firmware *uses* the crate.
- Physical-side-channel attacks (power, timing, EM).
- RF-regulatory compliance (firmware is intentionally transparent — see above).
+146
View File
@@ -0,0 +1,146 @@
# Phase 3 — Targeted analysis (raw notes)
Walked through every category in MYTHOS Phase 3 against the firmware's actual code paths. Most categories are not applicable to no_std embedded LoRa firmware with no networking, filesystem, shell, database, templates, deserializers, crypto, or auth. The categories that *do* apply have been traced in full below.
## Categories N/A by construction
- **SQL / NoSQL injection** — no database, no query language anywhere.
- **Command injection** — no `exec` / `spawn`; the firmware has no shell.
- **Template / SSTI** — `minijinja` is used only in `build.rs` at build time on a developer-controlled template (`src/board/mod.rs.j2`). Not exposed to runtime input.
- **LDAP / XPath / EL** — none.
- **Header injection / CRLF / log injection** — no HTTP, no syslog, no user-controlled string output.
- **Authentication / Authorization** — none exists; firmware does not implement auth (design choice, see threat model).
- **Cryptography** — none used; CRC-16/CCITT-FALSE is integrity-only, not security.
- **Deserialization** — no serde, pickle, yaml, marshal, BinaryFormatter, or other reflective deserializer. Every field is decoded byte-by-byte via fixed-offset slicing in `donglora-protocol`.
- **XSS / CSRF / CORS / Open redirect / Prototype pollution / ReDoS** — no JS, no HTML, no regex, no URL routing.
- **SSRF** — no outbound HTTP / DNS / proxy. The "outbound URL fetcher" analog would be `lora.tx()`, but the host fully controls every parameter by design.
- **File operations / Zip slip / Symlink** — no filesystem at runtime.
- **XML** — no XML parser.
## Categories that DO apply (and the traces)
### Memory safety in parsing pipelines
Walked from `host_task` byte ingress to every consumer:
**Frame decode (`donglora-protocol-1.2.0/src/frame.rs`):**
- `FrameDecoder::buf: [u8; MAX_COBS_ENCODED = 283]` — bounded.
- Index `self.len` only ever written via `if self.len < self.buf.len() { ... self.len += 1 }` — safe.
- On overflow → `overflowed = true; len = 0` — bytes are discarded silently until the next `0x00` sentinel resyncs; never out-of-bounds.
- On `0x00` → `let mut decoded = [0u8; MAX_PRE_COBS_FRAME = 280]; ucobs::decode(&self.buf[..self.len], &mut decoded)` returns `Option<usize>`; None → emit `FrameDecodeError::Cobs` and reset.
- `emit_decoded` requires `decoded.len() >= FRAME_HEADER_SIZE + FRAME_TRAILER_SIZE` (5 B); checks CRC; only then slices `type_id / tag / payload` from fixed offsets.
**Confirmed safe**: no path to an OOB read/write; no panic; no unbounded allocation.
**Command parse (`donglora-protocol-1.2.0/src/commands.rs::Command::parse`):**
- `TYPE_TX`: requires `!payload.is_empty()`; reads `payload[0]` for flags; rejects reserved bits via `TxFlags::from_byte`; rejects `body.is_empty()`; rejects `body.len() > MAX_OTA_PAYLOAD`; `extend_from_slice` is bounded by both the slice length and the heapless capacity (`MAX_OTA_PAYLOAD = 255`).
- `TYPE_SET_CONFIG`: every field byte feeds an `Option`-returning `from_u8` (rejects out-of-range), or a `match { 0 => false, 1 => true, _ => Err }` (rejects non-boolean), or an explicit length check `if buf.len() != WIRE_SIZE { Err }`.
- All other types require empty payload.
**Confirmed safe.**
**`validate_lora` (`src/radio.rs:2125-2145`):** numeric range checks on `freq_hz`, `sf`, `bw`, `tx_power_dbm`, `preamble_len`. Shift-by-`sf` is safe (`sf` already bounded ≤12 by the preceding check, so `1u16 << sf ≤ 4096`); shift-by-`bw.as_u8()` is safe (`bw` enum repr is ≤ 13, `1u16 << 13 = 8192`).
Not validated: `preamble_len` upper bound, `sync_word`, the post-parser-checked `header_mode`/`payload_crc`/`iq_invert`.
- `preamble_len = 65535` — fed to `lora.create_*_packet_params`. `toa_us` math (`src/lbt.rs:44-77`) uses u64 throughout and clamps to `u32::MAX`. With SF12/BW7.81 kHz/preamble 65535: `t_preamble_us = 65535 * 524520 ≈ 3.4e10` which is u64-safe and gets clamped to `u32::MAX = 4.29e9`. **No overflow.** The radio chip's HW register handles whatever value is loaded; reasonable lora-phy behavior expected.
- `sync_word = anything` — the memory note flags this as intentional (matches RadioLib/Semtech precedent). Confirmed not a vulnerability.
**Confirmed safe.**
**`try_start_rx_hw` / `cache_rx_pkt` (`src/radio.rs:1348+, 1628+`):** `MAX_OTA_PAYLOAD as u8 = 255` — current `MAX_OTA_PAYLOAD = 255` exactly fits `u8::MAX`. If the constant were ever raised above 255 the `as u8` would silently truncate — but the SX126x chip register is one byte wide so the constant is hardware-bound. No current bug.
**Confirmed safe.**
**`collect_rx_packet` (`src/radio.rs:1664-1702`):**
- `rx_buf: [u8; MAX_OTA_PAYLOAD]` — the buffer handed to `lora.get_rx_result(pkt, &mut rx_buf)`.
- The chip's `RX_PLD_LENGTH` is `255` (set in `try_start_rx_hw`), and the chip hardware register is one byte wide → `len` returned is bounded by 255 at the silicon level.
- `let copy_len = (len as usize).min(MAX_OTA_PAYLOAD)` — defensive clamp.
- `data.extend_from_slice(&ctx.rx_buf[..copy_len])` into `HVec<u8, MAX_OTA_PAYLOAD>` — bounds-safe.
**Confirmed safe.**
### Panic reachability from wire input
Catalog of panic sites and reachability:
| Location | Trigger | Reachable from wire? |
|---|---|---|
| `radio.rs:937` `NonZeroU16::new(tag).expect(...)` for `InboundEvent::Command` | tag=0 reaches radio task | No — `host/framing.rs:36-40` rejects tag=0 with `ERR(EFRAME)` before forwarding to `commands` channel |
| `radio.rs:1209-1213` `tag_or_panic` | Action reads `incoming_tag` when None | No — actions that read it only run after `next_command` has stashed a tag (chart shape) |
| `radio.rs:1753-1754` `PendingTx.tag was 0` | Pending TX with tag=0 | No — same framing layer rejects tag=0 |
| `radio.rs:2232-2235` `radio chart returned` | Chart `run()` returns | No — chart is non-terminating (all leaf states have `during:` activities; this would require a chart-design bug, not a wire input) |
| `main.rs:108, 113, 125, 138, 145` `.expect("…")` | Spawn / init failure | Boot-time only |
| `host/usb.rs:189` `from_utf8(MAC hex)` | Invalid UTF-8 in MAC hex | Hex chars are constants `0-9 A-F`; always valid ASCII |
**Confirmed: no panic site is reachable from network input.**
### `unsafe` audit
Four `unsafe` blocks in firmware code:
1. `src/main.rs:105` — `INFO = Some(info); INFO.as_ref().expect(...)`. Touched only at boot, in the single-threaded `run(spawner)` before any task is spawned. Subsequent reads via `&'static Info` reference passed by argument. **Sound.**
2. `src/hal/nrf52840.rs:102, 140` — `read_volatile` / `write_volatile` on `0x40000400` (POWER.RESETREAS). Valid MMIO. **Sound.**
3. `src/hal/nrf52840.rs:151` — `read_volatile` on `0x10000000.byte_add(0xA4/0xA8)` (FICR.DEVICEADDR). Valid MMIO. **Sound.**
4. `src/hal/rp2040.rs:68, 79` — `static mut MAC_REF` written once in `init_mac` (single-threaded init), read-only thereafter. **Sound.**
**Confirmed: no unsoundness in declared unsafe blocks.**
### TOCTOU / races
All cross-task communication uses `embassy_sync::channel::Channel` with `CriticalSectionRawMutex` — atomic enqueue/dequeue. Cross-task atomic flags (`RESET_PENDING`, `RADIO_THROTTLED`, `LORA_WEDGED`) use `portable_atomic::AtomicBool` with `AcqRel` swap or `Acquire/Release` loads/stores. The radio chip itself has exactly one user (the radio task), so there is no SPI-bus race.
**No exploitable race observed.**
### Information disclosure
USB serial-number descriptor = MAC address (hex). `GET_INFO` returns same MAC in `mcu_uid`. Either is readable by anyone enumerating the USB device or any successful `GET_INFO`, with no authentication.
- **Status:** Confirmed
- **Severity:** Info (privacy / cross-host tracking)
- **Reasoning:** This is the device's only stable cross-context identifier and it leaks unconditionally. A user reusing the dongle across hosts/networks is trivially correlatable.
### Dependency / supply chain
- `donglora-protocol 1.2.0` — sibling crate, fuzz-tested in `/home/swaits/Code/donglora-org/protocol/fuzz/`. No known CVEs.
- `ucobs 0.3` — small crate; no known CVEs.
- `lora-phy` — `git = "https://github.com/swaits/lora-rs.git", branch = "fixed"`, no `rev=` pin. Cargo.lock pins the current commit by SHA → reproducible *today*. But `cargo update -p lora-phy` will silently advance to whatever is on `branch=fixed` at that moment. If the maintainer's GitHub account is compromised, the next `cargo update` ingests the malicious commit.
- `heapless 0.9`, `embassy-* 0.5/0.6/0.10/0.8`, `defmt 0.3`, `esp-hal 1.1`, `embassy-usb 0.6` — all current versions; no known reachable CVE in their used surface.
The `branch=` (not `rev=`) git dep is the only flag — a **Low** finding (hardening).
### USB stack / control-transfer surface
`embassy_usb` handles control transfers internally. The firmware allocates `CTRL_BUF: [u8; 128]`. A code comment (`host/usb.rs:191-196`) records that the previous 64-byte size matched the panic-site message `"control buffer too small"` in `embassy_usb/src/lib.rs:758` — a real bug observed during enumeration. The 128-byte size is documented as safely above the largest CDC-ACM request.
**Status:** Mitigated; relies on third-party (embassy-usb 0.6) correctness for control-transfer parsing. Not an actionable firmware finding.
### Reliability bug worth noting (not strictly a security finding)
`lilygo_tbeam` (ESP32 classic) — the host transport is UART0 (`GPIO1/GPIO3 → CP2102 → host`). `esp-println` with the `auto` feature falls back to UART0 on ESP32 classic (no USB-Serial-JTAG peripheral). In production builds (`DEFMT_LOG` unset → `error!` only), if the radio chart enters `Dead` or similar `error!`-emitting paths, defmt bytes are emitted onto the same UART as the binary protocol, corrupting frames.
- **Status:** Suspected; not reachable from wire input (the error-level logs fire on hardware faults).
- **Severity:** Low (reliability, not security).
- **Why noted:** Documents a real frame-corruption scenario that an operator should be aware of when soak-testing the T-Beam classic.
For `heltec_v3_uart` and `elecrow_thinknode_m2` (both ESP32-S3, which has USB-Serial-JTAG), esp-println's `auto` selects USB-Serial-JTAG, which is a *different* peripheral than the host UART. Those boards are NOT affected.
### Intentional design choices flagged for transparency
These are NOT findings — they are deliberate, documented features. They appear here so the audit's reader knows they were considered and accepted:
- **No authentication on host transport.** A "dongle" trust model: physical attachment = full access.
- **No RF regulatory enforcement.** Frequency, sync word, and TX power are whatever the host sends (within hardware-supported range). FCC / CE / ETSI compliance is the *user's* responsibility.
- **TX `skip_cad=1` bypasses carrier-sense.** Documented in `PROTOCOL.md`; available to anyone with host-transport access.
## Items considered and dismissed
- Panic-on-malformed-frame (no — framing layer reflects `ERR(EFRAME)` and clears the decoder).
- Integer overflow in `toa_us` (no — u64 intermediates, u32 clamp; bound-checked).
- Buffer overrun on max-payload RX (no — chip HW register caps to 255; firmware buffer is sized to 255; `extend_from_slice` is bounded).
- Frame-decoder buffer exhaustion DoS (mitigated — overflow state silently discards bytes until resync; one packet, one frame; no accumulation across the limit).
- Stack overflow from deep call chains (no recursion; nested stack frames in `dispatch_bytes → feed → emit_decoded → Command::parse → Modulation::decode → LoRaConfig::decode` use ~1.5 KB total on a 4–32 KB task stack budget).
- Memory safety in the unsafe blocks (audited above).
- TOCTOU between USB read and radio dispatch (channel-mediated; no race).
- Side channels (timing on CRC compare, etc.) — irrelevant: there are no secrets to time-leak.
- `panic-probe` emitting panic message: emitted via `defmt-rtt`, which is a *different* hardware channel (SWD probe) than USB/UART. A panic locks the device (DoS) but does not leak to host or peer.
+216
View File
@@ -0,0 +1,216 @@
# Security Audit Report — donglora firmware v1.5.2
Auditor: **Mythos** (adversarial security review per `MYTHOS.md`)
Repo: `/home/swaits/Code/donglora-org/firmware`, HEAD `688938d` (chore: release 1.5.2)
Phases: Recon (`01-recon.md`) → Threats (`02-threats.md`) → Targeted analysis (`03-findings-draft.md`) → Verification → this report.
---
## Executive summary
**3 findings total: 0 Critical, 0 High, 0 Medium, 2 Low, 1 Info.**
The firmware is a small, tightly-scoped, no_std embedded Rust binary that exposes a SX126x/SX127x radio over a single host transport (USB CDC-ACM or UART). It performs **no networking, no filesystem I/O, no shell exec, no template rendering, no SQL/NoSQL/XML, no cryptography, and no deserialization beyond explicit byte slicing**. The whole vulnerability landscape MYTHOS targets is out of scope by construction; the realistic attack surface is exactly two paths: (1) inbound frames on the host transport, and (2) inbound LoRa packets on the configured RF parameters.
Both paths were traced end-to-end:
- **Host-transport ingress.** Every reachable byte goes through a CRC-checked, COBS-framed `FrameDecoder` → strict typed `Command::parse` → numeric-bounded `validate_lora`. Reserved bits, unknown enum values, length mismatches, and tag=0 are all explicitly rejected and turned into `ERR(...)` responses. No reachable panic, no buffer overrun, no integer overflow, no OOB access was found.
- **OTA RX ingress.** Payload bytes are bounded to 255 (SX126x register width); copied into a `heapless::Vec<u8, 255>` and forwarded to the host verbatim. The firmware never parses OTA bytes — there is no interpretation surface for a remote attacker to exploit.
The remaining findings concern **disclosure** (MAC visible in the USB serial descriptor — by design but worth documenting), **supply-chain hardening** (git dep not commit-pinned), and **reliability on one board variant** (esp-println output collides with host UART on lilygo_tbeam classic only). All are Low or Info severity.
**Top 3 systemic observations (NOT findings — design choices flagged for transparency):**
1. **No authentication on host transport.** Anyone with kernel-level access to the CDC-ACM/UART device node controls the radio. The product is intentionally a "transparent dongle" — physical attachment is the auth boundary.
2. **No RF-regulatory enforcement.** Frequency, sync word, and TX power are whatever the host commands, within hardware capability. FCC / CE / ETSI compliance is the user's responsibility.
3. **`TX skip_cad=1` bypasses listen-before-talk on demand.** Documented in `PROTOCOL.md`; available to any host-side actor.
These are deliberate features of a transparent LoRa bridge; treating them as bugs would be a category error. They are surfaced here so a security reader knows the trust model.
**Recommended remediation priorities:** None are urgent. The two Low findings can be addressed when convenient as hardening work; the Info finding is a documentation matter.
---
## Findings
### [LOW] lora-phy git dependency uses mutable branch ref, not a pinned commit
- **File(s):** `Cargo.toml:35`
- **Category:** Supply chain
- **Status:** Confirmed
- **Severity:** Low (CVSS-ish 4.4 — depends on a future `cargo update` and a maintainer-account compromise; reproducible builds are not affected today)
- **CWE:** CWE-829 (Inclusion of functionality from untrusted control sphere)
#### Summary
`lora-phy` is sourced from a git fork via `branch = "fixed"` rather than `rev = "<sha>"`. `Cargo.lock` currently pins the specific commit `043d7659…` (confirmed in `Cargo.lock:632-640`), so builds in this exact tree are reproducible. However, any future `cargo update -p lora-phy` (or running on a developer machine without a committed lockfile, which the firmware repo _does_ commit) will silently advance to whatever sits on `branch=fixed` at that moment. If the `swaits/lora-rs` GitHub account were compromised, the next `cargo update` ingests the malicious commit and the next-built firmware ships it.
#### Data flow
1. `Cargo.toml:35` declares `lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fixed", features = ["defmt-03"] }`.
2. `Cargo.lock:634` records the current resolved commit hash.
3. Any contributor running `cargo update` (or any auto-update tooling on the maintainer's machine) replaces the lockfile entry with whatever is at HEAD of the branch at update time.
4. The resulting binary executes whatever the new HEAD commit contains, with no human-readable diff at the dependency boundary unless someone inspects the lockfile delta.
#### Proof of concept
Reproducibility today is fine — `cargo build` against the committed lockfile uses commit `043d7659…`. The exposure is **temporal**: the moment `cargo update -p lora-phy` runs, the lockfile takes whatever commit is then on the branch. There is no PoC to demonstrate beyond reading the manifest.
#### Impact
A maintainer-account compromise (GitHub credential theft, PAT leak, repo-write malware) lets the attacker push a backdoored commit to the `fixed` branch. The next build that runs `cargo update` ships the backdoor into every flashed dongle. Severity is bounded by (a) the rate at which `cargo update` is run and (b) the difficulty of compromising the maintainer's GitHub. It's not zero — pinning to a commit closes the window.
#### Fix
Switch the manifest from `branch = "..."` to `rev = "<commit_sha>"`:
```toml
lora-phy = { git = "https://github.com/swaits/lora-rs.git", rev = "043d7659dfe2148913f16b2f7ea3e9241642b770", features = ["defmt-03"] }
```
Re-pin (bump the `rev`) on each lora-phy upgrade. Drops the temporal exposure entirely; the dep changes only when a human edits the manifest. `TODO.md` already plans to drop the fork once upstream PRs merge — at that point switch back to a crates.io version, which gets the same effect from the registry's immutability.
---
### [LOW] On `lilygo_tbeam` (ESP32 classic) only, esp-println output collides with host UART
- **File(s):** `src/board/lilygo_tbeam.rs:112-120`, `Cargo.toml:73-77`, `src/main.rs:53-57`
- **Category:** Wire-level reliability / information disclosure
- **Status:** Confirmed (frame corruption when error-level logs fire); not reachable from wire input today
- **Severity:** Low (reliability with disclosure side-effects; not directly attacker-triggerable)
- **CWE:** CWE-209 (Information Exposure Through an Error Message) — secondary
#### Summary
For the `lilygo_tbeam` build target (ESP32 classic, SX1276, CP2102-bridged UART), the host transport is `UART0` on `GPIO1/GPIO3`. `esp-println 0.17` is enabled with the `auto` (default) + `defmt-espflash` features. On ESP32 classic there is no USB-Serial-JTAG peripheral, so `auto` falls back to `UART0` — the _same_ UART the host protocol uses. Any defmt output (in production builds, only `error!` survives the compile-time filter) writes bytes onto the host wire mid-frame, corrupting the binary protocol the host is reading.
For `heltec_v3_uart` and `elecrow_thinknode_m2` (ESP32-S3, which _does_ have USB-Serial-JTAG), `esp-println auto` selects USB-Serial-JTAG — a _different_ peripheral. Those boards are unaffected.
#### Data flow
1. `Cargo.toml:73-77` pulls `esp-println` with `defmt-espflash` (no `uart` / `jtag-serial` feature explicitly set → `auto` picks at runtime).
2. `src/main.rs:53-57` installs `esp_println` as defmt's global handler for the esp boards.
3. `src/board/lilygo_tbeam.rs:113-120` initializes `Uart::new(p.UART0, ...).with_tx(p.GPIO1).with_rx(p.GPIO3)` as the host transport.
4. The ESP32 classic has no `USB_SERIAL_JTAG` peripheral → esp-println's runtime auto-selector falls back to UART0.
5. When a `defmt::error!(...)` or `defmt::panic!(...)` line executes on this board, esp-println writes bytes to UART0 → host sees them interleaved with COBS-framed protocol bytes.
#### Reachability from wire input
Walked the `error!` and `panic!` callsites: all fire only on hardware faults (`Recovering: exhausted ... transitioning to Dead`, `radio chart entered Dead state`, `lora cold init failed`, chart-invariant panics that are themselves not wire-reachable). **None are reachable from a malformed command.** Treat as a reliability concern, not a wire-attack.
#### Proof of concept
Run firmware compiled for `lilygo_tbeam`, intentionally induce a hardware-side error (e.g., disconnect the SX1276 SPI bus while the host is sending commands). Observe `defmt error!` output corrupting the COBS frames on the same UART. Without hardware tampering there is no PoC.
#### Impact
If the chip ever wedges hard enough to drive the chart into `Recovering` exhaustion or `Dead`, the host suddenly sees frame-CRC errors caused by defmt bytes instead of the chip-failure response it should have received. The host's framing layer recovers at the next clean `0x00` sentinel, but the lost frames could be the very `ERadio` reply that would have signaled the underlying failure. **Diagnostic blindness during hardware failure** on this one board variant.
#### Fix
Pick one of these for the lilygo_tbeam build:
- **Option A (preferred):** add `"no-op"` to the `esp-println` feature set for the `lilygo_tbeam` board feature, so the defmt global handler discards output entirely. Trade-off: no `probe-rs` defmt log capability on this board. Acceptable since the board has no SWD path anyway — it's CP2102-only.
- **Option B:** explicitly route esp-println to a _different_ UART (e.g., `UART1`) by adding the `uart` feature with manual pin selection. Requires reading the esp-println 0.17 docs to confirm the pin override hook.
This is also the spirit of the existing `debug-checkpoint` feature for UART-only boards (the LED-blink workaround), per `Cargo.toml:139-144`. The same "no shared UART for protocol + logs" rule should apply.
---
### [INFO] MAC address disclosed in USB serial descriptor and `GET_INFO` without authentication
- **File(s):** `src/host/usb.rs:182-189`, `src/main.rs:159-161, 180-181`
- **Category:** Information disclosure / privacy
- **Status:** Confirmed
- **Severity:** Info (intentional disclosure of a stable device identifier; standard for USB devices but worth documenting)
- **CWE:** CWE-200 (Exposure of Sensitive Information)
#### Summary
The device's 6-byte hardware MAC (nRF52840 FICR.DEVICEADDR / ESP32 eFuse / RP2040 flash-UID-derived) appears in two places, both readable without any authentication:
1. The USB CDC-ACM serial-number string descriptor (12 chars uppercase hex, no separator).
2. `GET_INFO.mcu_uid` (first 6 bytes; remaining 26 are zero).
Both surfaces are present at every boot. USB descriptors are read by every OS during enumeration without user consent; `GET_INFO` is wire-reachable by anyone who can write to the CDC-ACM/UART device.
#### Data flow (USB descriptor)
1. `src/main.rs:99-109` — boot reads `parts.mac` via `board::Board::mac_address()`.
2. `src/main.rs:118-124` — MAC passed to `host_task`.
3. `src/host/usb.rs:185-189` — MAC bytes formatted as 12 ASCII hex chars; assigned to `config.serial_number`.
4. Embassy USB stack publishes it as USB string descriptor (the OS retrieves on enumeration).
#### Data flow (GET_INFO)
1. `src/main.rs:159-161` — `mcu_uid[..6].copy_from_slice(&mac); Info.mcu_uid_len = 6`.
2. Any host issuing a valid `PING`-tag-2-format `GET_INFO` frame gets the `Info` blob back, including the 6-byte MAC.
#### Impact
The MAC is a permanent, factory-burned device identifier. A user moving a dongle between hosts/networks (laptop ↔ desk; home ↔ office; conference Wi-Fi) is correlatable across contexts by any malicious userspace program that enumerates USB. The information is _useful_ — `donglora-mux` and friends rely on it to distinguish multiple dongles — so it cannot simply be removed.
**This is also the standard model for USB devices in general.** Every USB device with a serial-number descriptor exposes a stable identifier; the user gets no consent prompt at enumeration time. Treat this finding as documentation rather than a remediation request.
#### Fix (optional, only if you care about cross-host de-anonymization)
- Replace the MAC in the USB serial descriptor with a per-install random byte string generated once and stored in MCU non-volatile memory (if available). Keep the real MAC accessible via authenticated `GET_INFO` if/when authentication is added.
- Or: leave the descriptor MAC alone (it's the standard pattern) and document the privacy expectation in `README.md` so users understand the dongle is trackable.
The Info-severity rating is the right call: this is a transparency / documentation question, not an actionable bug.
---
## Appendix A — Out-of-scope items
The audit did not cover:
- **Host-side applications** that consume the protocol (`ai-bot`, `bridge`, `client-py`, `mux-rs`, `mux-py`, `client-rs`, `tools`). Each is a separate audit target.
- **The `donglora-protocol` crate's invariants** beyond how the firmware uses them. The sibling repo at `/home/swaits/Code/donglora-org/protocol/` already maintains a fuzz suite (`protocol/fuzz/`) and mutation tests (`protocol/mutants.out*`); those validate the codec independent of this audit.
- **Physical-layer / RF-regulatory compliance** (FCC / CE / ETSI). The firmware is _intentionally_ transparent; user is responsible.
- **Side-channel analysis** (timing, power, electromagnetic) — irrelevant on a device with no secrets.
- **Build-time compromise** (malicious `build.rs`, compromised toolchain, supply-chain attacks against rustc / espup / cargo). The Low finding above is the closest the audit gets to this surface.
- **Bootloader / DFU surface** (UF2 / Adafruit nRF52 bootloader / espflash). Those are upstream code paths; this audit covers the application image only.
- **Hardware tampering / cold-boot RAM scraping / glitching the MCU**. Out of scope for source-code audit.
## Appendix B — Negative findings (audited and concluded not vulnerable)
Each item below was checked at source level and confirmed safe. This list is the most useful part of the audit: it documents what was _examined_ and why a Critical / High finding does not exist.
- **Frame decoder buffer handling** (`donglora-protocol-1.2.0/src/frame.rs::FrameDecoder::feed`): fixed-size buf, bounded index, overflow → resync. No OOB. No panic. No unbounded growth.
- **CRC-16/CCITT-FALSE on every frame**: cryptographically weak (it's a CRC, not a MAC) but appropriate for transport integrity. Defends against the firmware acting on bit-flipped frames.
- **`Command::parse` for every type_id** (`donglora-protocol-1.2.0/src/commands.rs:133-181`): reserved bits, unknown enum values, length mismatches, empty payloads all rejected explicitly with typed errors that the firmware turns into `ERR(ELENGTH|EPARAM|EMODULATION|EUNKNOWN_CMD)`. No silent acceptance.
- **`Modulation::decode` and `LoRaConfig::decode`** (`donglora-protocol-1.2.0/src/modulation.rs:743-755, 224-263`): every byte either bound-checked by `from_u8` or `match` arms reject non-{0,1} booleans. `WrongLength` returns on size mismatch.
- **`validate_lora`** (`radio.rs:2125-2145`): freq, SF, BW, power, preamble bounds. The two unchecked fields (`preamble_len` upper bound and `sync_word`) were traced into downstream `toa_us` (u64 math with `u32::MAX` clamp — confirmed overflow-safe) and `set_lora_sync_word` (per memory-note, silent acceptance is intentional).
- **`try_start_rx_hw` → `prepare_for_rx` chain** (`radio.rs:1348-1438`): `MAX_OTA_PAYLOAD as u8 = 255` is hardware-bounded (SX126x payload-length register is one byte). No truncation today; would surface at compile-time if `MAX_OTA_PAYLOAD` ever changed.
- **`collect_rx_packet`** (`radio.rs:1664-1702`): `rx_buf: [u8; MAX_OTA_PAYLOAD]` matches the chip-level cap. `copy_len = len.min(MAX_OTA_PAYLOAD)`. `extend_from_slice` is bounds-safe.
- **Panic reachability from wire input**: 6 panic sites enumerated; all reach only via chart-shape violations, not from network input. Specifically, the `NonZeroU16::new(tag).expect(...)` at `radio.rs:937` is gated by the `if tag == 0 { ... return; }` at `host/framing.rs:36-40` _before_ the envelope can be enqueued.
- **`unsafe` blocks** (4 sites): one-time-init `static mut` patterns (sound under stated preconditions, before any task spawns) and MMIO `read_volatile` / `write_volatile` (sound for `RESETREAS` / `FICR.DEVICEADDR`).
- **Cross-task races / TOCTOU**: `embassy_sync::channel::Channel<CriticalSectionRawMutex, ...>` for every cross-task hop. `portable_atomic::AtomicBool` with `AcqRel` swaps for shared flags. The radio chip has exactly one user (radio task), so no SPI-bus race.
- **USB-descriptor / control-transfer handling**: `embassy_usb` third-party. The firmware's `CTRL_BUF = 128` is explicitly oversized to defeat a known panic site in the 0.5/0.6 era (documented in source comment at `host/usb.rs:191-196`).
- **`embassy_usb_driver::EndpointError` handling** (`host/usb.rs:281-313`): `Disabled` is logged at debug only (normal during enumeration), `BufferOverflow` at warn — and the decoder is reset and the read loop sleeps 100 ms to avoid burning the executor on a stuck driver.
- **Stack overflow from nested parse calls**: depth-bounded (`feed → emit_decoded → Command::parse → Modulation::decode → LoRaConfig::decode`); cumulative stack use ≈ 1.5 KB on a 4–32 KB task stack.
- **DoS via async-err flood**: `RADIO_THROTTLED` gates `emit_async_err`; outbound depth-64 ring absorbs bursts; the channel can never grow.
- **DoS via Reset queue flood**: `RESET_PENDING` AtomicBool dedupes injected resets; documented at `channel.rs:16-29`.
- **Reachable `defmt::panic!()` in `radio_task` epilogue**: gated by chart non-termination invariant (every leaf has a `during:` activity); not reachable by wire input.
- **TX path payload handling**: `pending_tx.data` is `heapless::Vec<u8, MAX_OTA_PAYLOAD>` — bounded at parse-time. `lora.tx()` writes whatever bytes the host supplied. No memory-corruption surface; no auth surface to bypass.
- **OTA RX → outbound queue**: the firmware does not interpret OTA bytes. Wire encoding (`events.rs:228-249`) is a fixed-layout memcpy with bound checks at the front (`if buf.len() < total { Err }`).
- **`InboundEvent::Reset` clears the radio chart`incoming_tag` slot** (`radio.rs:923-930`) so a tag-echoing action that runs after Reset doesn't pull a stale tag.
- **No deserializers that take untrusted input by reflection** (no serde, no XML, no YAML, no pickle, no protobuf with dynamic types).
- **No SQL, NoSQL, shell, template, regex, HTTP, DNS, file, zip, XML surfaces in this firmware.**
## Appendix C — Confidence and methodology
- **Confidence:** High for the negative-findings list (every claim was traced with file:line); High for the three findings (each verified by re-reading the cited code after writing the draft).
- **Method:** MYTHOS Phase 1–5 walkthrough as defined in `MYTHOS.md`. Static review only; no runtime fuzzing was performed in this pass. The donglora-protocol crate's own fuzz suite (sibling repo) is presumed to validate codec invariants and is not duplicated here.
- **Not done:** `cargo audit` / `cargo deny` were not run as part of this audit. Recommended as a CI addition (CHANGELOG.md mentions hardening pulses; this would fit naturally). At the time of writing, no known reachable CVE for any used dependency surface is known to the auditor.
## Self-check
- [x] Every finding has file + line numbers
- [x] Every Confirmed finding has a PoC, a data-flow trace, or an unambiguous "this is what's in the source today" reference (the Low / Info ratings make a remote PoC moot for the supply-chain and disclosure findings)
- [x] Every finding has a one-sentence impact statement
- [x] No findings are pure pattern-matches without reachability analysis
- [x] Severity ratings are calibrated — nothing rated above Low; that matches what the audit actually found
- [x] Negative findings appendix exists
- [x] No suggestions beyond what's needed to fix the actual bug