Skip to content

Firmware Architecture

Espilon-Firmware (v0.2.1, ESP-IDF v5.3.2) is a hollow shell: the binary contains zero offensive capability at rest. WiFi credentials and the Management Protocol server address live encrypted in NVS, no module is stored in flash, and a power cycle wipes RAM back to a clean slate. Every capability arrives as a signed ELF module loaded into IRAM (or PSRAM on the S3) over an encrypted channel.

Components

Component Role
main/espilon.c Boot sequence (NVS, crypto, ESPM, transport)
components/crypto/ HKDF key derivation, ChaCha20-Poly1305
components/comm/ C2 transport, dispatch, command registries, WiFi, GPRS, OTA, loader
components/espm/ The ESPM dynamic module loader (copied from espilon-test/ESPM)
components/nanopb/ Protobuf C2 message schema and codec

Boot sequence

flowchart TD
    A["app_main()"] --> B["init_nvs()"]
    B --> C["crypto_init()<br/>read master_key, derive 3 keys via HKDF,<br/>zeroize master_key from RAM"]
    C --> D["nvs_config_load()<br/>decrypt WiFi + server config"]
    D --> E["espm_registry_init()"]
    E --> F["espilon_cmd_registry_init()<br/>static + dynamic command registry"]
    F --> G["espm_persist_autoload()<br/>reload persisted modules"]
    G --> H["delay 1200 ms<br/>radio stabilization"]
    H --> I["com_init()<br/>WiFi STA or GPRS/PPP -> C2 client"]

crypto_init() reads the device master_key from the immutable fctry NVS partition, derives three keys with HKDF-SHA256, then immediately zeroizes master_key from RAM. See the security model for the key hierarchy.

Key hierarchy

A single per-device master_key derives three independent keys:

Derived key Algorithm Use
session_key HKDF-SHA256, info espilon-c2-v1 ChaCha20-Poly1305 transport
signing_key HKDF-SHA256, info espm-sign HMAC-SHA256 module verification
config_key HKDF-SHA256, info espilon-cfg-v1 Encrypted NVS config

master_key is never transmitted and is wiped from RAM right after derivation.

Command registries

Commands are dispatched after a frame is decrypted and decoded. There are two registries:

  • Static registry (11 built-ins): status, reboot, cmds, ota_update, ota_status, mod_chunk, mod_load, mod_stop, mod_list, mod_purge, mod_unpersist.
  • Dynamic registry (up to 32 slots): commands registered by loaded modules via cmd_register. Module commands cannot shadow static command names. On mod_stop, a module's commands are removed automatically.
flowchart LR
    RX["TCP line"] --> B64["base64 decode"] --> DEC["ChaCha20-Poly1305 decrypt + verify"]
    DEC -->|"auth fail"| DROP["drop silently"]
    DEC --> PB["protobuf decode"] --> DSP{"dispatch"}
    DSP --> ST["static command"]
    DSP --> DY["dynamic (module) command"]
    DSP --> UNK["unknown -> error"]

Transports

  • WiFi STA (CONFIG_NETWORK_WIFI) - station mode, exponential-backoff reconnect, C2 client task starts on IP_EVENT_STA_GOT_IP.
  • GPRS/PPP (CONFIG_NETWORK_GPRS) - one generic binary for all cellular boards; modem pins and APN read from NVS at runtime. C2 client task starts on IP_EVENT_PPP_GOT_IP. See GPRS boards.

Module loading

Modules load in two stages: mod_chunk streams the signed ELF into a staging buffer (supports out-of-order chunks), then mod_load verifies the HMAC, relocates the ELF for the chip architecture, allocates IRAM/PSRAM, and starts the module task. See the ESPM lifecycle.

BLE is on-demand

BLE is never initialized at boot. NimBLE starts the first time a module calls ble_controller_init(). Initializing it at boot would race the high-priority NimBLE task against command registration and corrupt the registry. Per-chip BLE availability is covered in supported boards.

Panic isolation

On Xtensa (ESP32, ESP32-S3) a module fault is caught, mapped to the owning module, and that module alone is killed; the device keeps running. On RISC-V (ESP32-C6, C3) an unhandled fault reboots the device.

See also