diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8e30d21776..ef997105ba 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -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) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 8296410f2f..2d4de52e8f 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -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 diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 8fd21f42bd..108605e4c9 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -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 diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index a49a5e34b3..9dffd5429e 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -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}; diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 062e4d0811..264218a3df 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -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 diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp new file mode 100644 index 0000000000..edee594bfe --- /dev/null +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +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(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 "; +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(exponent_le >> 24), static_cast(exponent_le >> 16), + static_cast(exponent_le >> 8), static_cast(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 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 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 731e6188df..32d30c9d07 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/tests/component_tests/esp32/config/signed_ota_ecdsa256_c6.yaml b/tests/component_tests/esp32/config/signed_ota_ecdsa256_c6.yaml new file mode 100644 index 0000000000..0d504e36a2 --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_ecdsa256_c6.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: ecdsa256 diff --git a/tests/component_tests/esp32/config/signed_ota_ecdsa_v1.yaml b/tests/component_tests/esp32/config/signed_ota_ecdsa_v1.yaml new file mode 100644 index 0000000000..8d0dad947a --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_ecdsa_v1.yaml @@ -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 diff --git a/tests/component_tests/esp32/config/signed_ota_external_rsa_s3.yaml b/tests/component_tests/esp32/config/signed_ota_external_rsa_s3.yaml new file mode 100644 index 0000000000..f63f3ab690 --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_external_rsa_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + signing_scheme: rsa3072 diff --git a/tests/component_tests/esp32/config/signed_ota_signing_key_s3.yaml b/tests/component_tests/esp32/config/signed_ota_signing_key_s3.yaml new file mode 100644 index 0000000000..1a42301e9e --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_signing_key_s3.yaml @@ -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 diff --git a/tests/component_tests/esp32/config/signed_ota_verification_keys_s3.yaml b/tests/component_tests/esp32/config/signed_ota_verification_keys_s3.yaml new file mode 100644 index 0000000000..28966eba40 --- /dev/null +++ b/tests/component_tests/esp32/config/signed_ota_verification_keys_s3.yaml @@ -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 diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 3f4d71ef2a..5620a220f8 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -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"), [ diff --git a/tests/components/esp32/test-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/test-signed_ota_external.esp32-s3-idf.yaml new file mode 100644 index 0000000000..fab1d922fc --- /dev/null +++ b/tests/components/esp32/test-signed_ota_external.esp32-s3-idf.yaml @@ -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 diff --git a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml deleted file mode 100644 index 5b57993e87..0000000000 --- a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml +++ /dev/null @@ -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