mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 08:55:36 +00:00
[esp32] Add software OTA downgrade protection (#17315)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user