Skip to content

feat(ota): cryptographic signature verification for vendor firmware #160

Description

@jibrilsharafi

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    cloudAWS IoT Core, MQTT, and cloud infrastructureenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions