[esp32] Add software OTA downgrade protection (#17315)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Keith Burzinski
2026-07-06 19:39:18 -05:00
committed by GitHub
parent 2dd7ac090f
commit d9998eff20
11 changed files with 254 additions and 0 deletions
+1
View File
@@ -16,6 +16,7 @@ CONF_COLOR_DEPTH = "color_depth"
CONF_CRC_ENABLE = "crc_enable"
CONF_DATA_BITS = "data_bits"
CONF_DRAW_ROUNDING = "draw_rounding"
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection"
CONF_ENABLED = "enabled"
CONF_GYROSCOPE_ODR = "gyroscope_odr"
CONF_GYROSCOPE_RANGE = "gyroscope_range"
+67
View File
@@ -11,6 +11,7 @@ from typing import Any
from esphome import yaml_util
import esphome.codegen as cg
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
import esphome.config_validation as cv
from esphome.const import (
CONF_ADVANCED,
@@ -29,6 +30,7 @@ from esphome.const import (
CONF_PATH,
CONF_PLATFORM_VERSION,
CONF_PLATFORMIO_OPTIONS,
CONF_PROJECT,
CONF_REF,
CONF_SAFE_MODE,
CONF_SIZE,
@@ -1098,6 +1100,50 @@ def _detect_variant(value):
return value
def _ota_downgrade_protection_errors(
project_version: str | None, signed_ota_enabled: bool
) -> list[cv.Invalid]:
"""Validate prerequisites for OTA downgrade protection.
Called only when the feature is enabled. Returns a ``cv.Invalid`` for each
unmet requirement: a dotted-numeric project version (the firmware version
compared on-device) and signed OTA (so the embedded version cannot be
forged).
"""
path = [CONF_FRAMEWORK, CONF_ADVANCED, CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]
errs: list[cv.Invalid] = []
if not project_version:
errs.append(
cv.Invalid(
f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires a "
f"'{CONF_PROJECT}' with a '{CONF_VERSION}' to be set in the "
f"'{CONF_ESPHOME}' section; this version is the firmware version "
"compared during OTA.",
path=path,
)
)
elif not re.fullmatch(r"\d+(\.\d+)*", project_version):
# The on-device comparison parses dotted-numeric versions only.
errs.append(
cv.Invalid(
f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires the "
f"'{CONF_PROJECT}' '{CONF_VERSION}' to be dotted-numeric (such "
f"as '1.2.3'), got '{project_version}'.",
path=path,
)
)
if not signed_ota_enabled:
errs.append(
cv.Invalid(
f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires "
f"'{CONF_SIGNED_OTA_VERIFICATION}' to be enabled; without signed "
"OTA the embedded version cannot be trusted.",
path=path,
)
)
return errs
def final_validate(config):
# Imported locally to avoid circular import issues
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
@@ -1303,6 +1349,14 @@ def final_validate(config):
"Binaries will NOT be signed automatically during build. "
"You must sign them externally before flashing."
)
if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]:
project = full_config[CONF_ESPHOME].get(CONF_PROJECT)
errs.extend(
_ota_downgrade_protection_errors(
project[CONF_VERSION] if project else None,
bool(advanced.get(CONF_SIGNED_OTA_VERIFICATION)),
)
)
if errs:
raise cv.MultipleInvalid(errs)
@@ -1540,6 +1594,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
min=8192, max=32768
),
cv.Optional(CONF_ENABLE_OTA_ROLLBACK, default=True): cv.boolean,
cv.Optional(
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False
): cv.boolean,
cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All(
cv.Schema(
{
@@ -2358,6 +2415,16 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE", True)
cg.add_define("USE_OTA_ROLLBACK")
# Enable software OTA downgrade protection. Embed the project version into
# the image's esp_app_desc_t so the OTA backend can compare it against the
# running version (final_validate guarantees a dotted-numeric project
# version and that signed OTA is enabled).
if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]:
project_version = CORE.config[CONF_ESPHOME][CONF_PROJECT][CONF_VERSION]
add_idf_sdkconfig_option("CONFIG_APP_PROJECT_VER_FROM_CONFIG", True)
add_idf_sdkconfig_option("CONFIG_APP_PROJECT_VER", project_version)
cg.add_define("USE_OTA_DOWNGRADE_PROTECTION")
# Enable signed app verification without hardware secure boot
if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION):
add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", True)
+28
View File
@@ -2,6 +2,34 @@
namespace esphome::ota {
bool version_is_older(const char *candidate, const char *reference) {
if (candidate == nullptr || reference == nullptr)
return false;
while (true) {
uint32_t a = 0;
while (*candidate >= '0' && *candidate <= '9') {
a = a * 10 + static_cast<uint32_t>(*candidate - '0');
candidate++;
}
uint32_t b = 0;
while (*reference >= '0' && *reference <= '9') {
b = b * 10 + static_cast<uint32_t>(*reference - '0');
reference++;
}
if (a != b)
return a < b;
// Components equal so far; advance past a single separator on each side.
const bool a_more = (*candidate == '.');
const bool b_more = (*reference == '.');
if (a_more)
candidate++;
if (b_more)
reference++;
if (!a_more && !b_more)
return false; // Both strings exhausted with all components equal.
}
}
#ifdef USE_OTA_STATE_LISTENER
OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
+15
View File
@@ -46,9 +46,24 @@ enum OTAResponseTypes {
OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90,
OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91,
OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92,
OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93,
OTA_RESPONSE_ERROR_UNKNOWN = 0xFF,
};
/** Compare two dotted-numeric version strings (such as "1.2.3").
*
* Returns true when @p candidate represents a strictly older (lower) firmware
* version than @p reference. Each dot-separated component is parsed as an
* integer and compared left-to-right; absent trailing components count as 0,
* so "1.2" and "1.2.0" are equal. Equal versions return false so that
* re-flashing the same version is permitted.
*
* Used for software OTA downgrade protection. Inputs come from the project
* version embedded in the signed firmware image, which is validated to be
* dotted-numeric at config time. Non-digit characters terminate a component.
*/
bool version_is_older(const char *candidate, const char *reference);
enum OTAState {
OTA_COMPLETED = 0,
OTA_STARTED,
@@ -9,6 +9,9 @@
#include <esp_ota_ops.h>
#include <esp_task_wdt.h>
#include <spi_flash_mmap.h>
#ifdef USE_OTA_DOWNGRADE_PROTECTION
#include <esp_app_desc.h>
#endif
namespace esphome::ota {
@@ -159,6 +162,23 @@ OTAResponseTypes IDFOTABackend::end() {
}
#endif
if (err == ESP_OK) {
#ifdef USE_OTA_DOWNGRADE_PROTECTION
// The image is written and (when signing is enabled) signature-verified by
// esp_ota_end(), so its embedded project version can be trusted. Reject the
// update if it is older than the running version by leaving the boot
// partition unchanged -- the staged image simply never boots.
esp_app_desc_t incoming;
esp_err_t desc_err = esp_ota_get_partition_description(this->partition_, &incoming);
if (desc_err != ESP_OK) {
// Couldn't read the staged image's version, so the comparison is skipped.
// Warn so the bypassed check is observable rather than silent.
ESP_LOGW(TAG, "Downgrade protection: could not read image version (err=0x%X); allowing update", desc_err);
} else if (version_is_older(incoming.version, ESPHOME_PROJECT_VERSION)) {
ESP_LOGE(TAG, "Rejecting downgrade: image version '%s' is older than running version '%s'", incoming.version,
ESPHOME_PROJECT_VERSION);
return OTA_RESPONSE_ERROR_VERSION_DOWNGRADE;
}
#endif
err = esp_ota_set_boot_partition(this->partition_);
if (err == ESP_OK) {
return OTA_RESPONSE_OK;
+1
View File
@@ -239,6 +239,7 @@
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
#define USE_OTA_ROLLBACK
#define USE_OTA_SIGNED_VERIFICATION
#define USE_OTA_DOWNGRADE_PROTECTION
#define USE_ESP32_MIN_CHIP_REVISION_SET
#define USE_ESP32_RTC_PREFERENCES
#define USE_ESP32_SRAM1_AS_IRAM
+6
View File
@@ -52,6 +52,7 @@ RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F
RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90
RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91
RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92
RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93
RESPONSE_ERROR_UNKNOWN = 0xFF
OTA_VERSION_1_0 = 1
@@ -157,6 +158,11 @@ _ERROR_MESSAGES: dict[int, str] = {
"the bootloader update without rebooting the device. If the device "
"fails to boot, recover it via a serial flash."
),
RESPONSE_ERROR_VERSION_DOWNGRADE: (
"The device rejected the update because it has OTA downgrade protection "
"enabled: the new firmware's version must be newer than the version the "
"device is currently running."
),
RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP",
}
+33
View File
@@ -13,6 +13,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANTS,
NetworkSdkconfigData,
_ota_downgrade_protection_errors,
_reconcile_network_sdkconfig,
)
from esphome.components.esp32.const import (
@@ -560,3 +561,35 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
# WiFi present alongside BT -> WiFi stack must stay enabled.
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None:
assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == []
def test_downgrade_protection_accepts_calendar_version() -> None:
assert _ota_downgrade_protection_errors("2024.12.0", signed_ota_enabled=True) == []
def test_downgrade_protection_requires_project_version() -> None:
errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=True)
assert len(errs) == 1
assert "version" in str(errs[0])
def test_downgrade_protection_rejects_non_numeric_version() -> None:
errs = _ota_downgrade_protection_errors("1.0-beta", signed_ota_enabled=True)
assert len(errs) == 1
assert "dotted-numeric" in str(errs[0])
def test_downgrade_protection_requires_signed_ota() -> None:
errs = _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=False)
assert len(errs) == 1
assert "signed_ota_verification" in str(errs[0])
def test_downgrade_protection_reports_all_unmet_requirements() -> None:
# No project version and no signing -> two distinct errors.
errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False)
assert len(errs) == 2
@@ -0,0 +1,22 @@
esphome:
project:
name: esphome.downgrade_test
version: "1.2.3"
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
enable_ota_downgrade_protection: true
signed_ota_verification:
signing_key: ../../components/esp32/dummy_signing_key.pem
signing_scheme: rsa3072
# wifi + ota so the IDF OTA backend compiles with USE_OTA_DOWNGRADE_PROTECTION.
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
+9
View File
@@ -0,0 +1,9 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# md5's to_code calls cg.add_define("USE_MD5"), which gates md5.h. C++ unit
# test builds that pull md5 in transitively (e.g. ota's host backend, which
# has an md5::MD5Digest member) need that define, otherwise md5.h compiles to
# nothing and the dependent headers fail to find md5::MD5Digest.
manifest.enable_codegen()
@@ -0,0 +1,52 @@
#include <gtest/gtest.h>
#include "esphome/components/ota/ota_backend.h"
namespace esphome::ota::testing {
// version_is_older(candidate, reference) == true means candidate is a downgrade
// and should be rejected.
TEST(VersionIsOlder, PatchOlder) {
EXPECT_TRUE(version_is_older("1.2.3", "1.2.4"));
EXPECT_FALSE(version_is_older("1.2.4", "1.2.3"));
}
TEST(VersionIsOlder, NumericNotLexical) {
// "1.10.0" is newer than "1.9.0" even though '1' < '9' lexically.
EXPECT_TRUE(version_is_older("1.9.0", "1.10.0"));
EXPECT_FALSE(version_is_older("1.10.0", "1.9.0"));
}
TEST(VersionIsOlder, MajorMinor) {
EXPECT_TRUE(version_is_older("1.9.9", "2.0.0"));
EXPECT_TRUE(version_is_older("1.2.9", "1.3.0"));
EXPECT_FALSE(version_is_older("2.0.0", "1.9.9"));
}
TEST(VersionIsOlder, EqualVersionsAllowed) {
// Re-flashing the same version must be permitted.
EXPECT_FALSE(version_is_older("1.2.3", "1.2.3"));
EXPECT_FALSE(version_is_older("2024.1.0", "2024.1.0"));
}
TEST(VersionIsOlder, DifferingComponentCounts) {
// Missing trailing components count as 0.
EXPECT_FALSE(version_is_older("1.2", "1.2.0"));
EXPECT_FALSE(version_is_older("1.2.0", "1.2"));
EXPECT_TRUE(version_is_older("1.2", "1.2.1"));
EXPECT_FALSE(version_is_older("1.2.1", "1.2"));
}
TEST(VersionIsOlder, CalendarVersions) {
EXPECT_TRUE(version_is_older("2024.12.0", "2025.1.0"));
EXPECT_FALSE(version_is_older("2025.1.0", "2024.12.0"));
}
TEST(VersionIsOlder, NullInputsAreSafe) {
EXPECT_FALSE(version_is_older(nullptr, "1.2.3"));
EXPECT_FALSE(version_is_older("1.2.3", nullptr));
EXPECT_FALSE(version_is_older(nullptr, nullptr));
}
} // namespace esphome::ota::testing