[ota] Multi-key OTA signature verification for external RSA signing (#17981)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
This commit is contained in:
Keith Burzinski
2026-08-05 14:24:38 -05:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
parent b521b5e1ca
commit 1cc83172ab
15 changed files with 672 additions and 13 deletions
+189 -1
View File
@@ -119,6 +119,7 @@ CONF_SIGNING_SCHEME = "signing_scheme"
CONF_SRAM1_AS_IRAM = "sram1_as_iram"
CONF_SUBTYPE = "subtype"
CONF_VERIFICATION_KEY = "verification_key"
CONF_VERIFICATION_KEYS = "verification_keys"
ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
@@ -147,6 +148,12 @@ SIGNING_SCHEMES = {
"ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME",
}
# A Secure Boot v2 image carries at most three signature blocks, and hardware
# secure boot exposes three eFuse key slots. The trusted-key list isn't bound by
# the per-image limit (an incoming image need only match one trusted key), but
# cap it at three to mirror those hardware limits.
SIGNED_OTA_MAX_KEYS = 3
# Chip variants that only support one V2 signing scheme.
# Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h.
# Variants not listed in either set support both RSA and ECDSA V2
@@ -1164,10 +1171,98 @@ def _ota_downgrade_protection_errors(
return errs
def _sbv2_rsa_key_digest(path: Path) -> bytes:
"""SHA-256 of a public key's Secure Boot v2 signature-block key region.
This hashes the 776-byte {n, e, rinv, m'} region exactly as the ROM lays it
out -- i.e. the value the device computes per signature block and the one
``espsecure digest-sbv2-public-key`` prints, not a hash of the DER key.
"""
import hashlib
import struct
from cryptography.exceptions import UnsupportedAlgorithm
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import (
load_pem_private_key,
load_pem_public_key,
)
data = path.read_bytes()
try:
if b"PUBLIC KEY" in data:
public_key = load_pem_public_key(data)
else:
# verification_keys only needs the public half; warn so the private
# key doesn't end up committed alongside the config.
_LOGGER.warning(
"'%s' is a private key, but '%s' needs only the public key. Use a "
"public-key PEM or the 64-hex digest (espsecure "
"digest-sbv2-public-key) so the private key stays out of your config.",
path,
CONF_VERIFICATION_KEYS,
)
public_key = load_pem_private_key(data, password=None).public_key()
except (ValueError, TypeError, UnsupportedAlgorithm) as err:
raise cv.Invalid(f"Could not load key '{path}': {err}") from err
if not isinstance(public_key, rsa.RSAPublicKey) or public_key.key_size != 3072:
raise cv.Invalid(
f"'{CONF_VERIFICATION_KEYS}' entries must be RSA-3072 keys; "
f"'{path}' is not."
)
numbers = public_key.public_numbers()
n, e = numbers.n, numbers.e
m = (-pow(n, -1, 1 << 32)) & 0xFFFFFFFF
rinv = (1 << (public_key.key_size * 2)) % n
blob = struct.pack(
"<384sI384sI",
n.to_bytes(384, "big")[::-1],
e,
rinv.to_bytes(384, "big")[::-1],
m,
)
return hashlib.sha256(blob).digest()
def _validate_trusted_key(value: Any) -> str:
"""Normalize a trusted key to its 64-hex-char signature-block digest.
Accepts either the digest directly (so CI can inject it without shipping a
key file) or a PEM key file whose digest is computed here. Typed ``Any``
because YAML hands validators the parsed value -- e.g. an unquoted ``0x...``
digest arrives as an int, which the guard below rejects with advice to quote.
"""
# An unquoted 0x... or all-digit digest is parsed by YAML as an int before it
# reaches here, so it never looks like a string digest -- reject it clearly
# rather than letting it fall through to cv.file_ as a bogus path.
if not isinstance(value, str):
raise cv.Invalid(
f"Expected a key file path or a 64-character hex digest, got {value!r}. "
f"Quote the digest so YAML keeps it as text (an unquoted '0x...' or "
f"all-digit value is parsed as a number)."
)
stripped = value.strip()
if re.fullmatch(r"[0-9A-Fa-f]{64}", stripped):
return stripped.lower()
# An all-hex value that isn't exactly 64 chars is a mangled digest, not a
# path: a truncated or 0x-prefixed CI variable would otherwise fall through
# and fail as "file not found", pointing at the wrong problem.
if re.fullmatch(r"(?:0x)?[0-9A-Fa-f]+", stripped):
raise cv.Invalid(
f"'{stripped}' looks like a key digest but must be exactly 64 hex "
f"characters (a SHA-256, no '0x' prefix); check for truncation."
)
return _sbv2_rsa_key_digest(cv.file_(value)).hex()
_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_SIGNING_KEY): cv.file_,
cv.Optional(CONF_VERIFICATION_KEY): cv.file_,
cv.Optional(CONF_VERIFICATION_KEYS): cv.All(
cv.ensure_list(_validate_trusted_key),
cv.Length(min=1, max=SIGNED_OTA_MAX_KEYS),
),
cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of(
*SIGNING_SCHEMES, lower=True
),
@@ -1201,9 +1296,15 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType:
block appended to each image, so verifying externally-signed binaries
needs no key in the config at all -- omitting both keys selects that
external-signing mode.
For external RSA (rsa3072, no signing key), an optional 'verification_keys'
list names the keys the running app trusts. ESPHome then verifies OTA
signatures against that compiled-in set instead of IDF's single-block
check, which enables key rotation and multi-provider backup keys.
"""
has_signing_key = CONF_SIGNING_KEY in config
has_verification_key = CONF_VERIFICATION_KEY in config
has_verification_keys = CONF_VERIFICATION_KEYS in config
scheme = config[CONF_SIGNING_SCHEME]
if has_signing_key and has_verification_key:
raise cv.Invalid(
@@ -1211,6 +1312,34 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType:
f"'{CONF_VERIFICATION_KEY}', not both.",
path=[CONF_VERIFICATION_KEY],
)
if has_verification_keys:
if scheme != "rsa3072":
raise cv.Invalid(
f"'{CONF_VERIFICATION_KEYS}' is only used with signing scheme "
f"'rsa3072' (externally-signed RSA images). With '{scheme}' the "
f"public key travels in each image's signature block.",
path=[CONF_VERIFICATION_KEYS],
)
if has_signing_key:
raise cv.Invalid(
f"'{CONF_VERIFICATION_KEYS}' verifies externally-signed images "
f"and cannot be combined with '{CONF_SIGNING_KEY}' (which signs "
f"during the build). Provide one or the other.",
path=[CONF_VERIFICATION_KEYS],
)
if has_verification_key:
raise cv.Invalid(
f"Provide at most one of '{CONF_VERIFICATION_KEY}' and "
f"'{CONF_VERIFICATION_KEYS}', not both.",
path=[CONF_VERIFICATION_KEYS],
)
keys = config[CONF_VERIFICATION_KEYS]
if len(set(keys)) != len(keys):
raise cv.Invalid(
f"'{CONF_VERIFICATION_KEYS}' entries must be unique (duplicate "
f"keys add nothing and waste a trusted-set slot).",
path=[CONF_VERIFICATION_KEYS],
)
if scheme == "ecdsa_v1":
if not has_signing_key and not has_verification_key:
raise cv.Invalid(
@@ -2556,9 +2685,68 @@ async def to_code(config):
# 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)
add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", True)
scheme = signed_ota[CONF_SIGNING_SCHEME]
# For externally-signed RSA images with a declared 'verification_keys'
# list, ESPHome verifies the OTA signature itself instead of using IDF's
# on-update check. IDF only matches the incoming image's first signature
# block against the running app's first, which blocks key rotation and
# multi-provider backup keys; ESPHome accepts an image signed by any key
# in the compiled-in trusted set. Without 'verification_keys' there is no
# trust anchor, so fall back to IDF's built-in check.
# The build still produces the padded unsigned image (via SECURE_
# SIGNED_APPS_NO_SECURE_BOOT above); only the on-update check moves.
# SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT defaults to y under
# SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it must be set explicitly:
# False to hand verification to ESPHome, True to keep IDF's check.
# Setting it False also drives the hidden CONFIG_SECURE_SIGNED_APPS to
# n; the 4 KiB padding and reserved signature sector the verifier
# depends on survive only because --secure-pad-v2 keys off
# CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME (set below), not that symbol.
external_rsa = scheme == "rsa3072" and CONF_SIGNING_KEY not in signed_ota
verification_keys = signed_ota.get(CONF_VERIFICATION_KEYS)
# verification_keys is accepted only for external RSA (rsa3072 with no
# signing_key), enforced in _validate_signed_ota_keys. Assert the
# post-condition so validator/codegen drift fails the build loudly
# instead of silently dropping the declared trust anchor and downgrading
# to IDF's single-block check.
assert not verification_keys or external_rsa
multi_key = external_rsa and verification_keys
# Turning IDF's on-update check off is global -- it also drops the
# signature check from esp_ota_set_boot_partition() on the partition-table
# path and safe_mode's recovery rollback. Both deliberately select an
# already-installed image (or an MD5-checked partition table), not a
# freshly-downloaded one, so ESPHome's verifier only needs to cover the
# app and bootloader OTA paths, where a new image is actually written.
add_idf_sdkconfig_option(
"CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", not multi_key
)
if multi_key:
cg.add_define("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY")
# Compile the trusted key digests in as the immutable trust anchor.
# Each is the SHA-256 of a key's signature-block region; the verifier
# accepts an OTA whose signature block matches one of these.
digests = [bytes.fromhex(k) for k in verification_keys]
# Echo the resolved digests so a stale or mistyped key (which builds
# cleanly but leaves the device updatable only by serial reflash) is
# visible in the build log.
_LOGGER.info(
"Signed OTA verification trusts %d key digest(s): %s",
len(digests),
", ".join(d.hex() for d in digests),
)
cg.add_define("OTA_TRUSTED_KEY_COUNT", len(digests))
cg.add_define(
"OTA_TRUSTED_KEY_DIGESTS",
cg.RawExpression(
"{"
+ ",".join(
"{" + ",".join(f"0x{b:02x}" for b in d) + "}" for d in digests
)
+ "}"
),
)
for key, flag in SIGNING_SCHEMES.items():
add_idf_sdkconfig_option(flag, scheme == key)
+17 -1
View File
@@ -151,7 +151,7 @@ async def final_step():
cg.add_define("USE_OTA_STATE_LISTENER")
FILTER_SOURCE_FILES = filter_source_files_from_platform(
_filter_backend_source_files = filter_source_files_from_platform(
{
"ota_backend_esp_idf.cpp": {
PlatformFramework.ESP32_ARDUINO,
@@ -167,3 +167,19 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
"ota_backend_host.cpp": {PlatformFramework.HOST_NATIVE},
}
)
def FILTER_SOURCE_FILES() -> list[str]:
files = _filter_backend_source_files()
# ota_signature_esp_idf.cpp implements multi-key OTA signature verification,
# compiled only when the esp32 component enables it (external RSA signed
# OTA sets USE_OTA_SIGNED_VERIFICATION_MULTI_KEY). The define is set only on
# ESP32/IDF, so this also excludes the file on every other platform. Filter
# it out otherwise so the (otherwise fully #ifdef'd-out) file isn't opened
# and parsed on every build.
if not any(
define.name == "USE_OTA_SIGNED_VERIFICATION_MULTI_KEY"
for define in CORE.defines
):
files.append("ota_signature_esp_idf.cpp")
return files
@@ -144,6 +144,9 @@ OTAResponseTypes IDFOTABackend::end() {
}
}
#ifdef USE_OTA_PARTITIONS
// A partition-table update carries an MD5 (checked by IDF), not a Secure Boot
// signature, and only re-points boot at an already-installed app -- so it is
// intentionally not run through the signature verifier below.
if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) {
return this->update_partition_table();
}
@@ -162,6 +165,16 @@ OTAResponseTypes IDFOTABackend::end() {
}
#endif
if (err == ESP_OK) {
#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY
// IDF's built-in on-update check is disabled for this scheme (it only
// matches the incoming image's first signature block against the running
// app's first). Verify here against every key the running app trusts, so
// rotation and backup keys are accepted. Leaving the boot partition
// unchanged means a rejected image never boots.
if (!this->verify_signed_image_(this->partition_)) {
return OTA_RESPONSE_ERROR_SIGNATURE_INVALID;
}
#endif
#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
@@ -54,6 +54,11 @@ class IDFOTABackend final {
#endif
private:
#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY
// Accept an image signed by any key the running app trusts (up to 3 blocks),
// so rotation and backup keys work. Fails closed. Covers app and bootloader.
bool verify_signed_image_(const esp_partition_t *incoming);
#endif
// Keep md5_ first since its digest_ is alignas(32) on DMA-SHA variants; md5_set_ stays last so buf_ packs tightly.
md5::MD5Digest md5_{};
esp_ota_handle_t update_handle_{0};
@@ -94,6 +94,18 @@ OTAResponseTypes IDFOTABackend::finalize_bootloader_update_(esp_err_t ota_end_er
if (ota_end_err != ESP_OK) {
return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY;
}
#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY
// The new bootloader is staged in partition_. IDF never signature-checks a
// bootloader image in this software-signed config -- esp_image_verify() skips
// it when is_bootloader() is true -- so without this a bootloader OTA would
// install unverified. Require a trusted signature, which means the bootloader
// must be externally signed and 4 KiB-padded, the same as the app.
if (!this->verify_signed_image_(this->partition_)) {
ESP_LOGE(TAG, "Bootloader image is not signed by a trusted key; a bootloader OTA requires an "
"externally-signed, 4 KiB-padded bootloader.bin");
return OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY;
}
#endif
esp_bootloader_desc_t bootloader_desc;
esp_err_t desc_err = esp_ota_get_bootloader_description(this->partition_, &bootloader_desc);
#ifdef USE_ESP32_SRAM1_AS_IRAM
@@ -0,0 +1,232 @@
#ifdef USE_ESP32
#include "ota_backend_esp_idf.h"
#ifdef USE_OTA_SIGNED_VERIFICATION_MULTI_KEY
#include "esphome/components/watchdog/watchdog.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <array>
#include <cstring>
#include <memory>
#include <new>
#include <esp_image_format.h>
#include <esp_partition.h>
#include <esp_rom_crc.h>
#include <mbedtls/md.h>
#include <mbedtls/rsa.h>
#include <mbedtls/sha256.h>
namespace esphome::ota {
static const char *const TAG = "ota.idf";
// Route the "Signature check: " prefix (and its per-block form) through one
// shared format string each, so the prefix is pooled once by the linker instead
// of duplicated at every call site. The level macro is forwarded so compile-time
// log-level stripping still applies.
#define OTA_IDF_SIG_LOG(level, msg) level(TAG, "Signature check: %s", msg)
#define OTA_IDF_SIG_LOG_BLOCK(level, i, msg) level(TAG, "Signature check: block %zu: %s", static_cast<size_t>(i), msg)
// Secure Boot v2 RSA-3072 signature block, as written by espsecure and stored
// in the 4 KiB sector following the (4 KiB-padded) app image. All bignum
// fields are byte-reversed to little-endian for the RSA accelerator; software
// verification reverses them back. See the espsecure "<BBxx32s384sI384sI384s"
// packing for the authoritative layout.
namespace {
constexpr uint8_t SIG_BLOCK_MAGIC = 0xE7;
constexpr uint8_t SIG_BLOCK_VERSION_RSA = 0x02;
constexpr size_t SIG_BLOCK_SIZE = 1216;
constexpr size_t SIG_SECTOR_ALIGN = 4096;
constexpr size_t SIG_BLOCK_MAX_COUNT = 3;
constexpr size_t RSA_3072_BYTES = 384;
constexpr size_t SHA256_BYTES = 32;
constexpr size_t OFFSET_KEY = 36; // start of the hashed public-key region
constexpr size_t KEY_REGION_LEN = 776; // n[384] + e[4] + rinv[384] + m[4]
constexpr size_t OFFSET_MODULUS = 36; // n[384], little-endian
constexpr size_t OFFSET_EXPONENT = 420; // e, uint32 little-endian
constexpr size_t OFFSET_SIGNATURE = 812; // signature[384], little-endian
constexpr size_t OFFSET_CRC = 1196; // crc32 over bytes [0, 1196)
// A public key is identified by the SHA-256 of its 776-byte key region, exactly
// as the ROM computes it. The trusted set is compiled into the app from the
// config's verification_keys (esp32 signed_ota codegen) -- an immutable anchor
// that, unlike the appendable signature sector, an OTA cannot enlarge.
using KeyDigest = std::array<uint8_t, SHA256_BYTES>;
constexpr uint8_t TRUSTED_KEY_DIGESTS[OTA_TRUSTED_KEY_COUNT][SHA256_BYTES] = OTA_TRUSTED_KEY_DIGESTS;
// A block is structurally valid if the magic, version, and CRC all check out.
// The CRC covers everything before it and uses the same ROM routine the
// bootloader validates the block with, so the check matches byte-for-byte.
bool block_is_valid(const uint8_t *block) {
if (block[0] != SIG_BLOCK_MAGIC || block[1] != SIG_BLOCK_VERSION_RSA) {
return false;
}
uint32_t stored_crc;
memcpy(&stored_crc, block + OFFSET_CRC, sizeof(stored_crc));
return esp_rom_crc32_le(0, block, OFFSET_CRC) == stored_crc;
}
bool key_digest_of(const uint8_t *block, KeyDigest &out) {
return mbedtls_sha256(block + OFFSET_KEY, KEY_REGION_LEN, out.data(), /*is224=*/0) == 0;
}
// The offset of the signature sector: the app length rounded up to 4 KiB.
bool signature_sector_offset(const esp_partition_t *part, size_t &out_offset) {
esp_partition_pos_t pos{.offset = part->address, .size = part->size};
esp_image_metadata_t meta{};
if (esp_image_get_metadata(&pos, &meta) != ESP_OK) {
return false;
}
// Bound the image length before rounding up so a crafted header can't
// overflow the addition; the image plus its signature sector must fit.
if (meta.image_len > part->size) {
return false;
}
out_offset = (meta.image_len + SIG_SECTOR_ALIGN - 1) & ~(SIG_SECTOR_ALIGN - 1);
return out_offset + SIG_BLOCK_SIZE <= part->size;
}
// SHA-256 over the 4 KiB-padded image, i.e. everything the signature covers.
// Returns false on a read or hash error so a hash failure is not later
// misreported as a signature mismatch.
bool image_digest(const esp_partition_t *part, size_t image_padded_len, uint8_t *out) {
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
bool ok = mbedtls_sha256_starts(&ctx, /*is224=*/0) == 0;
uint8_t buf[512];
for (size_t off = 0; ok && off < image_padded_len; off += sizeof(buf)) {
size_t chunk = std::min(sizeof(buf), image_padded_len - off);
if (esp_partition_read(part, off, buf, chunk) != ESP_OK || mbedtls_sha256_update(&ctx, buf, chunk) != 0) {
ok = false;
}
}
if (ok) {
ok = mbedtls_sha256_finish(&ctx, out) == 0;
}
mbedtls_sha256_free(&ctx);
return ok;
}
// Verify one RSA-PSS-3072-SHA256 signature block over the image digest. The
// block's modulus and signature are stored little-endian; reverse them in place
// -- block is the caller's scratch buffer, overwritten on the next iteration --
// rather than stacking a second 384-byte copy of each bignum.
bool rsa_pss_verify(uint8_t *block, const uint8_t *digest) {
std::reverse(block + OFFSET_MODULUS, block + OFFSET_MODULUS + RSA_3072_BYTES);
std::reverse(block + OFFSET_SIGNATURE, block + OFFSET_SIGNATURE + RSA_3072_BYTES);
uint32_t exponent_le;
memcpy(&exponent_le, block + OFFSET_EXPONENT, sizeof(exponent_le));
uint8_t exponent_be[4] = {static_cast<uint8_t>(exponent_le >> 24), static_cast<uint8_t>(exponent_le >> 16),
static_cast<uint8_t>(exponent_le >> 8), static_cast<uint8_t>(exponent_le)};
mbedtls_rsa_context rsa;
mbedtls_rsa_init(&rsa);
bool key_ok = mbedtls_rsa_import_raw(&rsa, block + OFFSET_MODULUS, RSA_3072_BYTES, nullptr, 0, nullptr, 0, nullptr, 0,
exponent_be, sizeof(exponent_be)) == 0 &&
mbedtls_rsa_complete(&rsa) == 0 &&
mbedtls_rsa_set_padding(&rsa, MBEDTLS_RSA_PKCS_V21, MBEDTLS_MD_SHA256) == 0;
bool verified = false;
if (!key_ok) {
// A setup/allocation failure (e.g. OOM right after the download) is not a
// signature mismatch -- log it distinctly so it isn't read as "wrong key".
OTA_IDF_SIG_LOG(ESP_LOGE, "RSA key setup failed");
} else {
verified =
mbedtls_rsa_rsassa_pss_verify(&rsa, MBEDTLS_MD_SHA256, SHA256_BYTES, digest, block + OFFSET_SIGNATURE) == 0;
}
mbedtls_rsa_free(&rsa);
return verified;
}
} // namespace
bool IDFOTABackend::verify_signed_image_(const esp_partition_t *incoming) {
// Verification re-hashes the full image (after esp_ota_end already did one
// pass), which can approach the task WDT budget on a large app. Extend it for
// the duration, mirroring the erase budget in begin().
const uint32_t verify_budget_ms = 15000 + (incoming->size >> 10) * 10;
watchdog::WatchdogManager watchdog(verify_budget_ms);
size_t incoming_sector;
if (!signature_sector_offset(incoming, incoming_sector)) {
OTA_IDF_SIG_LOG(ESP_LOGE, "cannot locate incoming signature sector");
return false;
}
uint8_t digest[SHA256_BYTES];
if (!image_digest(incoming, incoming_sector, digest)) {
OTA_IDF_SIG_LOG(ESP_LOGE, "cannot hash incoming image");
return false;
}
// Accept if any incoming block is signed by a compiled-in trusted key AND its
// signature verifies over the image. Iterating all blocks (not just the
// first) is the whole point -- it lets a bridge/backup key in a later block
// be the match. The trust check is against the immutable compiled-in set, so
// extra (self-signed) blocks an attacker appends carry keys we simply ignore.
// Heap-allocate the 1216-byte block for the duration of verification: this
// runs mid-OTA on the loop task, on top of the caller's live 1 KB OTA buffer
// and mbedtls's own ~1 KB verify scratch, so keeping it off the stack widens
// a thin margin. One short-lived allocation right before reboot is not the
// fragmentation pattern the project guards against. nothrow so an OOM here
// fails closed like every other error path, rather than aborting.
std::unique_ptr<uint8_t[]> block(new (std::nothrow) uint8_t[SIG_BLOCK_SIZE]);
if (!block) {
OTA_IDF_SIG_LOG(ESP_LOGE, "out of memory");
return false;
}
bool any_valid_block = false;
for (size_t i = 0; i < SIG_BLOCK_MAX_COUNT; i++) {
size_t off = incoming_sector + i * SIG_BLOCK_SIZE;
if (off + SIG_BLOCK_SIZE > incoming->size) {
break; // partition has no room for another block; done scanning
}
// A read fault is not "no trusted key" -- fail closed with a distinct error.
if (esp_partition_read(incoming, off, block.get(), SIG_BLOCK_SIZE) != ESP_OK) {
OTA_IDF_SIG_LOG_BLOCK(ESP_LOGE, i, "unreadable");
return false;
}
if (!block_is_valid(block.get())) {
OTA_IDF_SIG_LOG_BLOCK(ESP_LOGD, i, "absent or malformed");
continue;
}
any_valid_block = true;
KeyDigest incoming_key;
if (!key_digest_of(block.get(), incoming_key)) {
OTA_IDF_SIG_LOG_BLOCK(ESP_LOGE, i, "key hash failed");
return false;
}
bool trusted_key = false;
for (const auto &trusted : TRUSTED_KEY_DIGESTS) {
if (memcmp(incoming_key.data(), trusted, SHA256_BYTES) == 0) {
trusted_key = true;
break;
}
}
if (!trusted_key) {
OTA_IDF_SIG_LOG_BLOCK(ESP_LOGW, i, "signed by an untrusted key");
continue;
}
if (rsa_pss_verify(block.get(), digest)) {
OTA_IDF_SIG_LOG_BLOCK(ESP_LOGD, i, "verified with a trusted key");
return true;
}
OTA_IDF_SIG_LOG_BLOCK(ESP_LOGW, i, "trusted key failed to verify");
}
// Separate "not signed at all" from "signed by an untrusted key" -- the former
// otherwise reads as the latter on a device that only logs at INFO.
if (!any_valid_block) {
OTA_IDF_SIG_LOG(ESP_LOGE, "image has no signature block");
} else {
OTA_IDF_SIG_LOG(ESP_LOGE, "no trusted key produced a valid signature");
}
return false;
}
} // namespace esphome::ota
#endif // USE_OTA_SIGNED_VERIFICATION_MULTI_KEY
#endif // USE_ESP32
+7
View File
@@ -250,6 +250,13 @@
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
#define USE_OTA_ROLLBACK
#define USE_OTA_SIGNED_VERIFICATION
#define USE_OTA_SIGNED_VERIFICATION_MULTI_KEY
// Stub values for tooling; a real build's codegen emits these from verification_keys.
#define OTA_TRUSTED_KEY_COUNT 1
#define OTA_TRUSTED_KEY_DIGESTS \
{ \
{ 0 } \
}
#define USE_OTA_DOWNGRADE_PROTECTION
#define USE_ESP32_MIN_CHIP_REVISION_SET
#define USE_ESP32_RTC_PREFERENCES
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: ecdsa256
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
variant: esp32
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: ecdsa_v1
verification_key: ../../../components/esp32/dummy_signing_key_v1_ecdsa.pem
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: rsa3072
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: rsa3072
signing_key: ../../../components/esp32/dummy_signing_key.pem
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: rsa3072
verification_keys:
- ../../../components/esp32/dummy_signing_key.pem
+120
View File
@@ -270,6 +270,53 @@ def test_nvs_encryption_sdkconfig(
assert "PERMANENT and IRREVERSIBLE" in caplog.text
@pytest.mark.parametrize(
("fixture", "multi_key", "idf_on_update"),
[
# Externally-signed RSA with a declared trusted-key list hands
# verification to ESPHome's multi-key verifier, so IDF's single-block
# on-update check must be OFF. It defaults ON under
# SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it has to be set to False
# explicitly -- not merely omitted.
("signed_ota_verification_keys_s3.yaml", True, False),
# Externally-signed RSA without a trusted-key list has no trust anchor,
# so it falls back to IDF's built-in check.
("signed_ota_external_rsa_s3.yaml", False, True),
# Build-time signing and the other schemes keep IDF's check.
("signed_ota_signing_key_s3.yaml", False, True),
("signed_ota_ecdsa256_c6.yaml", False, True),
("signed_ota_ecdsa_v1.yaml", False, True),
],
)
def test_signed_ota_verification_sdkconfig(
fixture: str,
multi_key: bool,
idf_on_update: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Only external RSA disables IDF's on-update check and uses ESPHome's verifier."""
generate_main(component_config_path(fixture))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
# The padded, externally-signable image is always produced.
assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT") is True
# Explicit value (never left to the Kconfig default) decides who verifies.
assert (
sdkconfig.get("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT") is idf_on_update
)
defines = {define.name for define in CORE.defines}
assert ("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" in defines) is multi_key
if multi_key:
# The padding / reserved signature sector the verifier depends on keys
# off the RSA scheme symbol, not the hidden CONFIG_SECURE_SIGNED_APPS
# (which the explicit `n` above drives to n). Pin the real dependency.
assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME") is True
# The compiled-in trust anchor: the fixture lists one key.
define_values = {define.name: str(define.value) for define in CORE.defines}
assert define_values["OTA_TRUSTED_KEY_COUNT"] == "1"
assert "OTA_TRUSTED_KEY_DIGESTS" in define_values
@pytest.mark.parametrize(
("fixture", "expect_warning"),
[
@@ -707,6 +754,9 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None:
# V1 ECDSA: exactly one of signing key / verification key.
{"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"},
{"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"},
# External RSA with a compiled-in trusted-key list (digests).
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32]},
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "cd" * 32]},
],
)
def test_signed_ota_keys_valid_combinations(config: dict) -> None:
@@ -761,6 +811,34 @@ def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -
},
"not both",
),
# A trusted-key list only applies to external RSA.
(
{"signing_scheme": "ecdsa256", "verification_keys": ["ab" * 32]},
"only used with signing scheme 'rsa3072'",
),
# Can't both auto-sign and verify against a fixed trusted set.
(
{
"signing_scheme": "rsa3072",
"signing_key": "key.pem",
"verification_keys": ["ab" * 32],
},
"cannot be combined with",
),
# The singular V1 key and the RSA trusted-key list are mutually exclusive.
(
{
"signing_scheme": "rsa3072",
"verification_key": "key.bin",
"verification_keys": ["ab" * 32],
},
"at most one",
),
# Duplicate trusted keys are rejected.
(
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "ab" * 32]},
"must be unique",
),
],
)
def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
@@ -770,6 +848,48 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
_validate_signed_ota_keys(config)
def test_sbv2_rsa_key_digest_known_answer() -> None:
"""The compiled-in trust anchor is the block-format digest the device
computes per signature block; pin it to espsecure's known output for the
shipped dummy key so a future change to the derivation can't drift silently.
"""
from esphome.components.esp32 import _sbv2_rsa_key_digest
key = (
Path(__file__).parent.parent.parent
/ "components"
/ "esp32"
/ "dummy_signing_key.pem"
)
assert (
_sbv2_rsa_key_digest(key).hex()
== "957671f5ec1b55b3fb1d32c5525a68d3b8c33847922daddb4feefe64cd679f65"
)
def test_validate_trusted_key_hex_forms() -> None:
"""The digest-input branch: the same key as an uppercase 64-hex digest
normalizes to the PEM-derived value (the two forms are interchangeable), and
a mangled digest fails clearly instead of as a missing file.
"""
from esphome.components.esp32 import _sbv2_rsa_key_digest, _validate_trusted_key
key = (
Path(__file__).parent.parent.parent
/ "components"
/ "esp32"
/ "dummy_signing_key.pem"
)
pem_digest = _sbv2_rsa_key_digest(key).hex()
assert _validate_trusted_key(pem_digest.upper()) == pem_digest
for bad in (pem_digest[:-1], "0x" + pem_digest):
with pytest.raises(cv.Invalid, match="64 hex"):
_validate_trusted_key(bad)
# An unquoted 0x.../all-digit digest reaches the validator as a YAML int.
with pytest.raises(cv.Invalid, match="Quote the digest"):
_validate_trusted_key(0x957671F5EC1B55B3)
@pytest.mark.parametrize(
("value", "expected"),
[
@@ -0,0 +1,23 @@
# External RSA signing mode with a declared trusted-key list enables ESPHome's
# own multi-key OTA signature verifier (USE_OTA_SIGNED_VERIFICATION_MULTI_KEY),
# which accepts an image whose signature block matches one of the compiled-in
# trusted keys. wifi + ota pull in the ota component so CI actually compiles that
# verifier; allow_partition_access exercises the bootloader-update path too.
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
verification_keys:
- ../../components/esp32/dummy_signing_key.pem
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
allow_partition_access: true
<<: !include common.yaml
@@ -1,11 +0,0 @@
# Secure Boot V2 schemes carry the public key inside each image's signature
# block, so verifying externally-signed binaries needs no key in the config:
# a bare block enables verification with the default rsa3072 scheme.
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
<<: !include common.yaml