Summary
Add ECDSA P-256 / SHA-256 signature verification to both OTA paths (MQTT cloud + local web upload) so that vendor-shipped devices only accept firmware signed by EnergyMe's CI key. Community builds (built from source) bypass the check entirely. Signing happens in CI via AWS KMS.
Goal: protect vendor devices against malicious firmware pushed through a compromised cloud path or stolen admin credentials on the local web UI, without breaking the open-source build/flash workflow.
Current state
Both OTA paths currently accept any binary that passes basic structural checks. No cryptographic signature verification.
MQTT cloud OTA (src/mqtt.cpp:990-1056, _performOtaUpdate):
- Downloads via
esp_https_ota() from a presigned S3 URL passed in the AWS IoT job document
- TLS cert chain validates the S3 endpoint
- Post-reboot stability check compares running partition's
app_elf_sha256 against the value captured at download time
- No signature on the firmware contents themselves
Local web upload (src/customserver.cpp:960-1022, POST /api/v1/ota/upload):
- Protected by HTTP digest auth
- Uses Arduino's
Update.h library
- Optional MD5 verification via
X-MD5 header
- No signature check
Proposed design
Vendor / community split (build-time toggle)
Same model as ESPHome's signed OTA: a build flag, not a runtime decision.
- Vendor build: compiled with
-DUPDATE_SIGN. Embeds the EnergyMe public key as a const char[] in firmware. Both OTA paths verify signature before esp_ota_set_boot_partition().
- Community build: compiled without the flag. No verification code is linked in. Users build from source freely.
Rejected alternative: "skip check if pubkey not in NVS" or "skip if globalCommunityMode == true". Both are downgrade attacks: NVS is unencrypted on v1 devices, and an attacker who can write NVS could delete the key and push unsigned firmware. The bypass must be tamper-resistant. Build-time toggle is.
Single signed artifact for releases
One CI pipeline, one KMS sign step, one binary published to both GitHub Releases and the S3 cloud-OTA bucket. Vendor devices verify; community devices ignore. No two-channel release matrix.
Physical-presence demote-to-community
Required for the future hardening step (burning DIS_DOWNLOAD_MODE efuse). Long button hold at boot (~30s) + confirmation press wipes factory NVS (cert, key, pcb_revision, pubkey), sets a community_demoted=1 marker, reboots. One-way. Pattern: ChromeOS dev mode, Android fastboot oem unlock.
Without this, a vendor device with USB disabled has no recovery path if the KMS key is lost / a customer wants to migrate off the EnergyMe cloud / etc.
Rollback
No strict anti-rollback in firmware. The signature check already means only EnergyMe can produce binaries, so the "old signed binary with known bug" attack surface is narrow. Server-side blocklist (just don't push known-bad versions via MQTT) is sufficient. Local manual rollback to an old signed binary remains possible.
Phased implementation
Phase 1: Local web OTA verifier
- Add
-DUPDATE_SIGN to [common] common_build_flags in platformio.ini
- Embed P-256 PEM pubkey as
static const char[] in include/ota_keys.h
- At
src/customserver.cpp:1074, install UpdaterECDSAVerifier before Update.begin()
- CI: KMS sign step appending DER signature to
firmware.bin (Espressif's appended-signature layout)
- Diff: ~15 lines firmware + CI workflow changes
Phase 2: MQTT cloud OTA verifier
- After
esp_https_ota() completes in _performOtaUpdate(), re-read inactive partition with esp_partition_read(), hash with mbedtls, verify against signature passed in the AWS IoT job document (new signature_url or inline signature_b64 field)
- Verify before
esp_ota_set_boot_partition()
- Diff: ~50 lines
Phase 3: Physical-presence demote handler
- Long button hold (~30s) at boot detected in
src/buttonhandler.cpp boot flow
- LED feedback during countdown (
src/led.cpp)
- Confirmation press within 5s
- Wipe
factory_ns namespace, set demote marker, reboot
- Diff: ~50 lines
Phase 4: NVS encryption (separate track, prerequisite for burning USB efuses on future units)
- Call
nvs_flash_secure_init() with HMAC-scheme keys (eFuse KEY_PURPOSE = HMAC_UP). Symbols are linked in pioarduino's shipped libs - no sdkconfig change required.
- Migration path for v1 fleet: OTA to a version that initializes encrypted NVS on first boot, re-writes existing keys, marks migration complete.
What this does NOT secure
Important to be explicit:
- Physical reflash via esptool (UART or USB) - the verifier is in the app being replaced. Mitigated by burning
DIS_USB_SERIAL_JTAG + DIS_DOWNLOAD_MODE efuses at manufacturing (only on future units, not the v1 fleet).
- Bootloader swap - the pioarduino-shipped bootloader is unsigned. Becomes irrelevant once download mode is disabled.
- True Secure Boot V2 - requires a signed bootloader, which requires
CONFIG_SECURE_BOOT=y in sdkconfig, which Arduino framework blocks. Out of scope for v1.x; revisit only if we ever migrate to ESP-IDF (Arduino-as-component).
- KMS private key compromise - existential. Mitigations: HSM-backed key, restrict to CI role with GitHub OIDC + branch conditional access, have key-rotation firmware ready (sign new pubkey with old key, ship in OTA, device pins new key in NVS).
- Cert exfiltration from a stolen device - attacker can impersonate that one device in AWS IoT. Mitigated per-device via AWS IoT cert revocation.
Open questions
- Signature delivery in MQTT job document: separate
signature_url (clean, but extra download) vs. inline signature_b64 (saves a round-trip, ~96 bytes for DER P-256 signature). Probably inline.
- Whether to ship the demote-to-community handler in Phase 1 or wait until Phase 4 / pre-v2 hardening. Likely Phase 3 timing is fine since v1 fleet has USB open anyway.
- Whether to add a
security label to the repo for this and future similar work.
References
Summary
Add ECDSA P-256 / SHA-256 signature verification to both OTA paths (MQTT cloud + local web upload) so that vendor-shipped devices only accept firmware signed by EnergyMe's CI key. Community builds (built from source) bypass the check entirely. Signing happens in CI via AWS KMS.
Goal: protect vendor devices against malicious firmware pushed through a compromised cloud path or stolen admin credentials on the local web UI, without breaking the open-source build/flash workflow.
Current state
Both OTA paths currently accept any binary that passes basic structural checks. No cryptographic signature verification.
MQTT cloud OTA (
src/mqtt.cpp:990-1056,_performOtaUpdate):esp_https_ota()from a presigned S3 URL passed in the AWS IoT job documentapp_elf_sha256against the value captured at download timeLocal web upload (
src/customserver.cpp:960-1022,POST /api/v1/ota/upload):Update.hlibraryX-MD5headerProposed design
Vendor / community split (build-time toggle)
Same model as ESPHome's signed OTA: a build flag, not a runtime decision.
-DUPDATE_SIGN. Embeds the EnergyMe public key as aconst char[]in firmware. Both OTA paths verify signature beforeesp_ota_set_boot_partition().Rejected alternative: "skip check if pubkey not in NVS" or "skip if
globalCommunityMode == true". Both are downgrade attacks: NVS is unencrypted on v1 devices, and an attacker who can write NVS could delete the key and push unsigned firmware. The bypass must be tamper-resistant. Build-time toggle is.Single signed artifact for releases
One CI pipeline, one KMS sign step, one binary published to both GitHub Releases and the S3 cloud-OTA bucket. Vendor devices verify; community devices ignore. No two-channel release matrix.
Physical-presence demote-to-community
Required for the future hardening step (burning
DIS_DOWNLOAD_MODEefuse). Long button hold at boot (~30s) + confirmation press wipes factory NVS (cert, key, pcb_revision, pubkey), sets acommunity_demoted=1marker, reboots. One-way. Pattern: ChromeOS dev mode, Androidfastboot oem unlock.Without this, a vendor device with USB disabled has no recovery path if the KMS key is lost / a customer wants to migrate off the EnergyMe cloud / etc.
Rollback
No strict anti-rollback in firmware. The signature check already means only EnergyMe can produce binaries, so the "old signed binary with known bug" attack surface is narrow. Server-side blocklist (just don't push known-bad versions via MQTT) is sufficient. Local manual rollback to an old signed binary remains possible.
Phased implementation
Phase 1: Local web OTA verifier
-DUPDATE_SIGNto[common] common_build_flagsinplatformio.inistatic const char[]ininclude/ota_keys.hsrc/customserver.cpp:1074, installUpdaterECDSAVerifierbeforeUpdate.begin()firmware.bin(Espressif's appended-signature layout)Phase 2: MQTT cloud OTA verifier
esp_https_ota()completes in_performOtaUpdate(), re-read inactive partition withesp_partition_read(), hash with mbedtls, verify against signature passed in the AWS IoT job document (newsignature_urlor inlinesignature_b64field)esp_ota_set_boot_partition()Phase 3: Physical-presence demote handler
src/buttonhandler.cppboot flowsrc/led.cpp)factory_nsnamespace, set demote marker, rebootPhase 4: NVS encryption (separate track, prerequisite for burning USB efuses on future units)
nvs_flash_secure_init()with HMAC-scheme keys (eFuseKEY_PURPOSE = HMAC_UP). Symbols are linked in pioarduino's shipped libs - no sdkconfig change required.What this does NOT secure
Important to be explicit:
DIS_USB_SERIAL_JTAG+DIS_DOWNLOAD_MODEefuses at manufacturing (only on future units, not the v1 fleet).CONFIG_SECURE_BOOT=yin sdkconfig, which Arduino framework blocks. Out of scope for v1.x; revisit only if we ever migrate to ESP-IDF (Arduino-as-component).Open questions
signature_url(clean, but extra download) vs. inlinesignature_b64(saves a round-trip, ~96 bytes for DER P-256 signature). Probably inline.securitylabel to the repo for this and future similar work.References