mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[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:
co-authored by
pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
parent
b521b5e1ca
commit
1cc83172ab
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user