From bff257258e5842be055b0ca9084fbda56822aec5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 14:33:37 -0500 Subject: [PATCH 01/47] preen --- esphome/components/esphome/ota/__init__.py | 2 +- .../components/esphome/ota/ota_esphome.cpp | 170 +++++++++++++----- esphome/components/ota/ota_backend.h | 1 + esphome/components/sha256/__init__.py | 14 ++ esphome/components/sha256/sha256.cpp | 131 ++++++++++++++ esphome/components/sha256/sha256.h | 36 ++++ esphome/core/defines.h | 1 + esphome/espota2.py | 55 ++++-- 8 files changed, 344 insertions(+), 66 deletions(-) create mode 100644 esphome/components/sha256/__init__.py create mode 100644 esphome/components/sha256/sha256.cpp create mode 100644 esphome/components/sha256/sha256.h diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 93216f9425..c8bb055c16 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] -AUTO_LOAD = ["md5", "socket"] +AUTO_LOAD = ["md5", "sha256", "socket"] DEPENDENCIES = ["network"] esphome = cg.esphome_ns.namespace("esphome") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 6654ef8748..23e58de3e6 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,6 +1,9 @@ #include "ota_esphome.h" #ifdef USE_OTA #include "esphome/components/md5/md5.h" +#ifdef USE_SHA256 +#include "esphome/components/sha256/sha256.h" +#endif #include "esphome/components/network/util.h" #include "esphome/components/ota/ota_backend.h" #include "esphome/components/ota/ota_backend_arduino_esp32.h" @@ -95,6 +98,111 @@ void ESPHomeOTAComponent::loop() { } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; +#ifdef USE_SHA256 +static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; +#endif + +// Template traits for hash algorithms +template struct HashTraits; + +template<> struct HashTraits { + static constexpr int nonce_size = 8; + static constexpr int hex_size = 32; + static constexpr const char *name = "MD5"; + static constexpr ota::OTAResponseTypes auth_request = ota::OTA_RESPONSE_REQUEST_AUTH; +}; + +#ifdef USE_SHA256 +template<> struct HashTraits { + static constexpr int nonce_size = 16; + static constexpr int hex_size = 64; + static constexpr const char *name = "SHA256"; + static constexpr ota::OTAResponseTypes auth_request = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; +}; +#endif + +// Template helper for hash-based authentication +template bool perform_hash_auth(ESPHomeOTAComponent *ota, const std::string &password) { + using Traits = HashTraits; + + // Minimize stack usage by reusing buffers + // We only need 2 buffers at most at the same time + constexpr size_t hex_buffer_size = Traits::hex_size + 1; + + // These two buffers are reused throughout the function + char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result + char hex_buffer2[hex_buffer_size]; // Used for: cnonce -> response + + // Small stack buffer for auth request and nonce seed + uint8_t buf[1]; + char nonce_seed[17]; // Max: "%08x%08x" = 16 chars + null + + // Send auth request type + buf[0] = Traits::auth_request; + ota->writeall_(buf, 1); + + HashClass hasher; + hasher.init(); + + // Generate nonce seed + if (Traits::nonce_size == 8) { + sprintf(nonce_seed, "%08" PRIx32, random_uint32()); + } else { + sprintf(nonce_seed, "%08" PRIx32 "%08" PRIx32, random_uint32(), random_uint32()); + } + hasher.add(nonce_seed, Traits::nonce_size); + hasher.calculate(); + + // Use hex_buffer1 for nonce + hasher.get_hex(hex_buffer1); + hex_buffer1[Traits::hex_size] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::name, hex_buffer1); + + // Send nonce + if (!ota->writeall_(reinterpret_cast(hex_buffer1), Traits::hex_size)) { + ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::name); + return false; + } + + // Prepare challenge + hasher.init(); + hasher.add(password.c_str(), password.length()); + hasher.add(hex_buffer1, Traits::hex_size); // Add nonce + + // Receive cnonce into hex_buffer2 + if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::hex_size)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::name); + return false; + } + hex_buffer2[Traits::hex_size] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::name, hex_buffer2); + + // Add cnonce to hash + hasher.add(hex_buffer2, Traits::hex_size); + + // Calculate result - reuse hex_buffer1 for expected + hasher.calculate(); + hasher.get_hex(hex_buffer1); + hex_buffer1[Traits::hex_size] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::name, hex_buffer1); + + // Receive response - reuse hex_buffer2 + if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::hex_size)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::name); + return false; + } + hex_buffer2[Traits::hex_size] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::name, hex_buffer2); + + // Compare + bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::hex_size) == 0; + + if (!matches) { + ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::name); + } + + return matches; +} void ESPHomeOTAComponent::handle_handshake_() { /// Handle the initial OTA handshake. @@ -225,57 +333,23 @@ void ESPHomeOTAComponent::handle_data_() { #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { - buf[0] = ota::OTA_RESPONSE_REQUEST_AUTH; - this->writeall_(buf, 1); - md5::MD5Digest md5{}; - md5.init(); - sprintf(sbuf, "%08" PRIx32, random_uint32()); - md5.add(sbuf, 8); - md5.calculate(); - md5.get_hex(sbuf); - ESP_LOGV(TAG, "Auth: Nonce is %s", sbuf); + bool auth_success = false; - // Send nonce, 32 bytes hex MD5 - if (!this->writeall_(reinterpret_cast(sbuf), 32)) { - ESP_LOGW(TAG, "Auth: Writing nonce failed"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) +#ifdef USE_SHA256 + // Check if client supports SHA256 auth + bool use_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + + if (use_sha256) { + // Use SHA256 for authentication + auth_success = perform_hash_auth(this, this->password_); + } else +#endif // USE_SHA256 + { + // Fall back to MD5 for backward compatibility (or when SHA256 is not available) + auth_success = perform_hash_auth(this, this->password_); } - // prepare challenge - md5.init(); - md5.add(this->password_.c_str(), this->password_.length()); - // add nonce - md5.add(sbuf, 32); - - // Receive cnonce, 32 bytes hex MD5 - if (!this->readall_(buf, 32)) { - ESP_LOGW(TAG, "Auth: Reading cnonce failed"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - sbuf[32] = '\0'; - ESP_LOGV(TAG, "Auth: CNonce is %s", sbuf); - // add cnonce - md5.add(sbuf, 32); - - // calculate result - md5.calculate(); - md5.get_hex(sbuf); - ESP_LOGV(TAG, "Auth: Result is %s", sbuf); - - // Receive result, 32 bytes hex MD5 - if (!this->readall_(buf + 64, 32)) { - ESP_LOGW(TAG, "Auth: Reading response failed"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - sbuf[64 + 32] = '\0'; - ESP_LOGV(TAG, "Auth: Response is %s", sbuf + 64); - - bool matches = true; - for (uint8_t i = 0; i < 32; i++) - matches = matches && buf[i] == buf[64 + i]; - - if (!matches) { - ESP_LOGW(TAG, "Auth failed! Passwords do not match"); + if (!auth_success) { error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 372f24df5e..64ee0b9f7c 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -14,6 +14,7 @@ namespace ota { enum OTAResponseTypes { OTA_RESPONSE_OK = 0x00, OTA_RESPONSE_REQUEST_AUTH = 0x01, + OTA_RESPONSE_REQUEST_SHA256_AUTH = 0x02, OTA_RESPONSE_HEADER_OK = 0x40, OTA_RESPONSE_AUTH_OK = 0x41, diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py new file mode 100644 index 0000000000..4b4be4616e --- /dev/null +++ b/esphome/components/sha256/__init__.py @@ -0,0 +1,14 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import coroutine_with_priority + +CODEOWNERS = ["@esphome/core"] + +sha256_ns = cg.esphome_ns.namespace("sha256") + +CONFIG_SCHEMA = cv.All(cv.Schema({})) + + +@coroutine_with_priority(1.0) +async def to_code(config): + cg.add_define("USE_SHA256") diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp new file mode 100644 index 0000000000..1723fc6851 --- /dev/null +++ b/esphome/components/sha256/sha256.cpp @@ -0,0 +1,131 @@ +#include "sha256.h" +#include "esphome/core/helpers.h" +#include + +#ifdef USE_ESP32 +#include "mbedtls/sha256.h" +#elif defined(USE_ESP8266) || defined(USE_RP2040) +#include +#endif + +namespace esphome { +namespace sha256 { + +#ifdef USE_ESP32 +struct SHA256::SHA256Context { + mbedtls_sha256_context ctx; + uint8_t hash[32]; +}; + +SHA256::~SHA256() { + if (this->ctx_) { + mbedtls_sha256_free(&this->ctx_->ctx); + } +} + +void SHA256::init() { + if (!this->ctx_) { + this->ctx_ = std::make_unique(); + } + mbedtls_sha256_init(&this->ctx_->ctx); + mbedtls_sha256_starts(&this->ctx_->ctx, 0); // 0 = SHA256, not SHA224 +} + +void SHA256::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + mbedtls_sha256_update(&this->ctx_->ctx, data, len); +} + +void SHA256::calculate() { + if (!this->ctx_) { + this->init(); + } + mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); +} + +#elif defined(USE_ESP8266) || defined(USE_RP2040) + +struct SHA256::SHA256Context { + ::SHA256 sha; + uint8_t hash[32]; + bool calculated{false}; +}; + +SHA256::~SHA256() = default; + +void SHA256::init() { + if (!this->ctx_) { + this->ctx_ = std::make_unique(); + } + this->ctx_->sha.reset(); + this->ctx_->calculated = false; +} + +void SHA256::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + this->ctx_->sha.update(data, len); +} + +void SHA256::calculate() { + if (!this->ctx_) { + this->init(); + } + if (!this->ctx_->calculated) { + this->ctx_->sha.finalize(this->ctx_->hash, 32); + this->ctx_->calculated = true; + } +} + +#else +#error "SHA256 not supported on this platform" +#endif + +void SHA256::get_bytes(uint8_t *output) { + if (!this->ctx_) { + memset(output, 0, 32); + return; + } + memcpy(output, this->ctx_->hash, 32); +} + +void SHA256::get_hex(char *output) { + if (!this->ctx_) { + memset(output, '0', 64); + output[64] = '\0'; + return; + } + for (size_t i = 0; i < 32; i++) { + sprintf(output + i * 2, "%02x", this->ctx_->hash[i]); + } +} + +std::string SHA256::get_hex_string() { + char buf[65]; + this->get_hex(buf); + return std::string(buf); +} + +bool SHA256::equals_bytes(const uint8_t *expected) { + if (!this->ctx_) { + return false; + } + return memcmp(this->ctx_->hash, expected, 32) == 0; +} + +bool SHA256::equals_hex(const char *expected) { + if (!this->ctx_) { + return false; + } + uint8_t parsed[32]; + if (!parse_hex(expected, parsed, 32)) { + return false; + } + return this->equals_bytes(parsed); +} + +} // namespace sha256 +} // namespace esphome diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h new file mode 100644 index 0000000000..4d94c5b77d --- /dev/null +++ b/esphome/components/sha256/sha256.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/core/defines.h" +#include +#include +#include + +namespace esphome { +namespace sha256 { + +class SHA256 { + public: + SHA256() = default; + ~SHA256(); + + void init(); + void add(const uint8_t *data, size_t len); + void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + void add(const std::string &data) { this->add(data.c_str(), data.length()); } + + void calculate(); + + void get_bytes(uint8_t *output); + void get_hex(char *output); + std::string get_hex_string(); + + bool equals_bytes(const uint8_t *expected); + bool equals_hex(const char *expected); + + protected: + struct SHA256Context; + std::unique_ptr ctx_; +}; + +} // namespace sha256 +} // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6e8d5ed74c..052ef11ec4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -115,6 +115,7 @@ #define USE_API_PLAINTEXT #define USE_API_SERVICES #define USE_MD5 +#define USE_SHA256 #define USE_MQTT #define USE_NETWORK #define USE_ONLINE_IMAGE_BMP_SUPPORT diff --git a/esphome/espota2.py b/esphome/espota2.py index 3d25af985b..8afd3a0a72 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -14,6 +14,7 @@ from esphome.helpers import resolve_ip_address RESPONSE_OK = 0x00 RESPONSE_REQUEST_AUTH = 0x01 +RESPONSE_REQUEST_SHA256_AUTH = 0x02 RESPONSE_HEADER_OK = 0x40 RESPONSE_AUTH_OK = 0x41 @@ -44,6 +45,7 @@ OTA_VERSION_2_0 = 2 MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45] FEATURE_SUPPORTS_COMPRESSION = 0x01 +FEATURE_SUPPORTS_SHA256_AUTH = 0x02 UPLOAD_BLOCK_SIZE = 8192 @@ -209,10 +211,14 @@ def perform_ota( f"Device uses unsupported OTA version {version}, this ESPHome supports {supported_versions}" ) - # Features - send_check(sock, FEATURE_SUPPORTS_COMPRESSION, "features") + # Features - send both compression and SHA256 auth support + features_to_send = FEATURE_SUPPORTS_COMPRESSION | FEATURE_SUPPORTS_SHA256_AUTH + send_check(sock, features_to_send, "features") features = receive_exactly( - sock, 1, "features", [RESPONSE_HEADER_OK, RESPONSE_SUPPORTS_COMPRESSION] + sock, + 1, + "features", + None, # Accept any response )[0] if features == RESPONSE_SUPPORTS_COMPRESSION: @@ -221,31 +227,46 @@ def perform_ota( else: upload_contents = file_contents - (auth,) = receive_exactly( - sock, 1, "auth", [RESPONSE_REQUEST_AUTH, RESPONSE_AUTH_OK] - ) - if auth == RESPONSE_REQUEST_AUTH: + def perform_auth(sock, password, hash_func, nonce_size, hash_name): + """Perform challenge-response authentication using specified hash algorithm.""" if not password: raise OTAError("ESP requests password, but no password given!") + nonce = receive_exactly( - sock, 32, "authentication nonce", [], decode=False + sock, nonce_size, f"{hash_name} authentication nonce", [], decode=False ).decode() - _LOGGER.debug("Auth: Nonce is %s", nonce) - cnonce = hashlib.md5(str(random.random()).encode()).hexdigest() - _LOGGER.debug("Auth: CNonce is %s", cnonce) + _LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce) + + # Generate cnonce + cnonce = hash_func(str(random.random()).encode()).hexdigest() + _LOGGER.debug("Auth: %s CNonce is %s", hash_name, cnonce) send_check(sock, cnonce, "auth cnonce") - result_md5 = hashlib.md5() - result_md5.update(password.encode("utf-8")) - result_md5.update(nonce.encode()) - result_md5.update(cnonce.encode()) - result = result_md5.hexdigest() - _LOGGER.debug("Auth: Result is %s", result) + # Calculate challenge response + hasher = hash_func() + hasher.update(password.encode("utf-8")) + hasher.update(nonce.encode()) + hasher.update(cnonce.encode()) + result = hasher.hexdigest() + _LOGGER.debug("Auth: %s Result is %s", hash_name, result) send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) + (auth,) = receive_exactly( + sock, + 1, + "auth", + [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], + ) + if auth == RESPONSE_REQUEST_SHA256_AUTH: + # SHA256 authentication + perform_auth(sock, password, hashlib.sha256, 64, "SHA256") + elif auth == RESPONSE_REQUEST_AUTH: + # MD5 authentication (backward compatibility) + perform_auth(sock, password, hashlib.md5, 32, "MD5") + # Set higher timeout during upload sock.settimeout(30.0) From 853d3ae331d1b26abde4d992fe52f63d00e3eaee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 14:46:49 -0500 Subject: [PATCH 02/47] preen --- esphome/components/esphome/ota/__init__.py | 18 ++++++++- .../components/esphome/ota/ota_esphome.cpp | 40 +++++++++++++------ esphome/components/sha256/__init__.py | 9 ----- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index c8bb055c16..5579b9ec37 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_SAFE_MODE, CONF_VERSION, ) -from esphome.core import coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv @@ -24,9 +24,17 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] -AUTO_LOAD = ["md5", "sha256", "socket"] DEPENDENCIES = ["network"] + +def AUTO_LOAD(): + """Conditionally auto-load sha256 only on platforms that support it.""" + base_components = ["md5", "socket"] + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040: + return base_components + ["sha256"] + return base_components + + esphome = cg.esphome_ns.namespace("esphome") ESPHomeOTAComponent = esphome.class_("ESPHomeOTAComponent", OTAComponent) @@ -126,6 +134,12 @@ FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_port(config[CONF_PORT])) + + # Only include SHA256 support on platforms that have it + # This prevents including unnecessary SHA256 code on platforms like LibreTiny + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040: + cg.add_define("USE_OTA_SHA256") + if CONF_PASSWORD in config: cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_PASSWORD") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 23e58de3e6..06fd119c07 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,7 +1,7 @@ #include "ota_esphome.h" #ifdef USE_OTA #include "esphome/components/md5/md5.h" -#ifdef USE_SHA256 +#ifdef USE_OTA_SHA256 #include "esphome/components/sha256/sha256.h" #endif #include "esphome/components/network/util.h" @@ -98,7 +98,7 @@ void ESPHomeOTAComponent::loop() { } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; -#ifdef USE_SHA256 +#ifdef USE_OTA_SHA256 static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; #endif @@ -112,7 +112,7 @@ template<> struct HashTraits { static constexpr ota::OTAResponseTypes auth_request = ota::OTA_RESPONSE_REQUEST_AUTH; }; -#ifdef USE_SHA256 +#ifdef USE_OTA_SHA256 template<> struct HashTraits { static constexpr int nonce_size = 16; static constexpr int hex_size = 64; @@ -133,9 +133,9 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result char hex_buffer2[hex_buffer_size]; // Used for: cnonce -> response - // Small stack buffer for auth request and nonce seed + // Small stack buffer for auth request and nonce seed bytes uint8_t buf[1]; - char nonce_seed[17]; // Max: "%08x%08x" = 16 chars + null + uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) // Send auth request type buf[0] = Traits::auth_request; @@ -144,13 +144,29 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co HashClass hasher; hasher.init(); - // Generate nonce seed + // Generate nonce seed bytes + uint32_t r1 = random_uint32(); + // Convert first uint32 to bytes (always needed for MD5) + nonce_bytes[0] = (r1 >> 24) & 0xFF; + nonce_bytes[1] = (r1 >> 16) & 0xFF; + nonce_bytes[2] = (r1 >> 8) & 0xFF; + nonce_bytes[3] = r1 & 0xFF; + if (Traits::nonce_size == 8) { - sprintf(nonce_seed, "%08" PRIx32, random_uint32()); - } else { - sprintf(nonce_seed, "%08" PRIx32 "%08" PRIx32, random_uint32(), random_uint32()); + // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 + hasher.add(nonce_bytes, 4); } - hasher.add(nonce_seed, Traits::nonce_size); +#ifdef USE_OTA_SHA256 + else { + // SHA256: 16 chars = "%08x%08x" format = 8 bytes from two random uint32s + uint32_t r2 = random_uint32(); + nonce_bytes[4] = (r2 >> 24) & 0xFF; + nonce_bytes[5] = (r2 >> 16) & 0xFF; + nonce_bytes[6] = (r2 >> 8) & 0xFF; + nonce_bytes[7] = r2 & 0xFF; + hasher.add(nonce_bytes, 8); + } +#endif hasher.calculate(); // Use hex_buffer1 for nonce @@ -335,7 +351,7 @@ void ESPHomeOTAComponent::handle_data_() { if (!this->password_.empty()) { bool auth_success = false; -#ifdef USE_SHA256 +#ifdef USE_OTA_SHA256 // Check if client supports SHA256 auth bool use_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; @@ -343,7 +359,7 @@ void ESPHomeOTAComponent::handle_data_() { // Use SHA256 for authentication auth_success = perform_hash_auth(this, this->password_); } else -#endif // USE_SHA256 +#endif // USE_OTA_SHA256 { // Fall back to MD5 for backward compatibility (or when SHA256 is not available) auth_success = perform_hash_auth(this, this->password_); diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index 4b4be4616e..e24da86e25 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -1,14 +1,5 @@ import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.core import coroutine_with_priority CODEOWNERS = ["@esphome/core"] sha256_ns = cg.esphome_ns.namespace("sha256") - -CONFIG_SCHEMA = cv.All(cv.Schema({})) - - -@coroutine_with_priority(1.0) -async def to_code(config): - cg.add_define("USE_SHA256") From f15c83462c8d8c2dafbeb0b865431013ba3df635 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 15:06:51 -0500 Subject: [PATCH 03/47] preen --- esphome/components/esphome/ota/__init__.py | 5 ++--- esphome/components/sha256/sha256.cpp | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 5579b9ec37..3134fadf26 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -30,7 +30,7 @@ DEPENDENCIES = ["network"] def AUTO_LOAD(): """Conditionally auto-load sha256 only on platforms that support it.""" base_components = ["md5", "socket"] - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: return base_components + ["sha256"] return base_components @@ -136,8 +136,7 @@ async def to_code(config): cg.add(var.set_port(config[CONF_PORT])) # Only include SHA256 support on platforms that have it - # This prevents including unnecessary SHA256 code on platforms like LibreTiny - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: cg.add_define("USE_OTA_SHA256") if CONF_PASSWORD in config: diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 1723fc6851..3788c28741 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -4,7 +4,7 @@ #ifdef USE_ESP32 #include "mbedtls/sha256.h" -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) #include #endif @@ -45,7 +45,7 @@ void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); } -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) struct SHA256::SHA256Context { ::SHA256 sha; From 080fe6eae5bd0837c92f8cb0edf41bb7b64b96a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 15:11:52 -0500 Subject: [PATCH 04/47] preen --- esphome/components/sha256/sha256.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 3788c28741..a0f82b78ef 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -4,7 +4,7 @@ #ifdef USE_ESP32 #include "mbedtls/sha256.h" -#elif defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) +#elif defined(USE_ARDUINO) #include #endif @@ -45,7 +45,7 @@ void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); } -#elif defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) +#elif defined(USE_ARDUINO) struct SHA256::SHA256Context { ::SHA256 sha; From 8b765715d67d8b2ef5f93b514ab7bdfc98c65c34 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Sep 2025 15:17:25 -0500 Subject: [PATCH 05/47] preen --- esphome/components/sha256/sha256.cpp | 6 ++---- esphome/components/sha256/sha256.h | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index a0f82b78ef..699579251e 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -8,8 +8,7 @@ #include #endif -namespace esphome { -namespace sha256 { +namespace esphome::sha256 { #ifdef USE_ESP32 struct SHA256::SHA256Context { @@ -127,5 +126,4 @@ bool SHA256::equals_hex(const char *expected) { return this->equals_bytes(parsed); } -} // namespace sha256 -} // namespace esphome +} // namespace esphome::sha256 diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 4d94c5b77d..dd1742ea0d 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -5,8 +5,7 @@ #include #include -namespace esphome { -namespace sha256 { +namespace esphome::sha256 { class SHA256 { public: @@ -32,5 +31,4 @@ class SHA256 { std::unique_ptr ctx_; }; -} // namespace sha256 -} // namespace esphome +} // namespace esphome::sha256 From 46f05b34e5722c7f749e5fa801b88a691f91279f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 15:45:38 -0600 Subject: [PATCH 06/47] preen --- esphome/components/esphome/ota/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 3134fadf26..370f50fcbc 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -27,10 +27,15 @@ CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] +def supports_sha256() -> bool: + """Check if the current platform supports SHA256 for OTA authentication.""" + return bool(CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny) + + def AUTO_LOAD(): """Conditionally auto-load sha256 only on platforms that support it.""" base_components = ["md5", "socket"] - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: + if supports_sha256(): return base_components + ["sha256"] return base_components @@ -136,7 +141,7 @@ async def to_code(config): cg.add(var.set_port(config[CONF_PORT])) # Only include SHA256 support on platforms that have it - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: + if supports_sha256(): cg.add_define("USE_OTA_SHA256") if CONF_PASSWORD in config: From 6215199c1aec0c2d11c7dbd9316841cf8ead3284 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 15:51:52 -0600 Subject: [PATCH 07/47] codeowners --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index e91116795a..77a837df0d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -407,6 +407,7 @@ esphome/components/sensor/* @esphome/core esphome/components/sfa30/* @ghsensdev esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw +esphome/components/sha256/* @esphome/core esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me esphome/components/sht4x/* @sjtrny From e721e8c2037f1205b4c8b674d75606e7ab6b5dcf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 15:54:17 -0600 Subject: [PATCH 08/47] preen --- esphome/components/esphome/ota/__init__.py | 2 +- esphome/components/esphome/ota/ota_esphome.cpp | 2 -- esphome/espota2.py | 9 ++++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 370f50fcbc..72a690b926 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -32,7 +32,7 @@ def supports_sha256() -> bool: return bool(CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny) -def AUTO_LOAD(): +def AUTO_LOAD() -> list[str]: """Conditionally auto-load sha256 only on platforms that support it.""" base_components = ["md5", "socket"] if supports_sha256(): diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 06fd119c07..8b6235e247 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -102,7 +102,6 @@ static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; #endif -// Template traits for hash algorithms template struct HashTraits; template<> struct HashTraits { @@ -121,7 +120,6 @@ template<> struct HashTraits { }; #endif -// Template helper for hash-based authentication template bool perform_hash_auth(ESPHomeOTAComponent *ota, const std::string &password) { using Traits = HashTraits; diff --git a/esphome/espota2.py b/esphome/espota2.py index 8215c14cb3..33176cc35f 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -9,6 +9,7 @@ import random import socket import sys import time +from typing import Any from esphome.core import EsphomeError from esphome.helpers import resolve_ip_address @@ -228,7 +229,13 @@ def perform_ota( else: upload_contents = file_contents - def perform_auth(sock, password, hash_func, nonce_size, hash_name): + def perform_auth( + sock: socket.socket, + password: str, + hash_func: Any, + nonce_size: int, + hash_name: str, + ) -> None: """Perform challenge-response authentication using specified hash algorithm.""" if not password: raise OTAError("ESP requests password, but no password given!") From 0919669fc6eae29d0c271392362593e1ee25ebf7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 15:56:21 -0600 Subject: [PATCH 09/47] preen --- esphome/espota2.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 33176cc35f..dc4fa7237b 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import gzip import hashlib import io @@ -55,6 +56,12 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 _LOGGER = logging.getLogger(__name__) +# Authentication method lookup table: response -> (hash_func, nonce_size, name) +_AUTH_METHODS: dict[int, tuple[Callable[[], Any], int, str]] = { + RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), + RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), +} + class ProgressBar: def __init__(self): @@ -262,18 +269,24 @@ def perform_ota( send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) + # Authentication method lookup table + auth_methods = { + RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), + RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), + } + (auth,) = receive_exactly( sock, 1, "auth", [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], ) - if auth == RESPONSE_REQUEST_SHA256_AUTH: - # SHA256 authentication - perform_auth(sock, password, hashlib.sha256, 64, "SHA256") - elif auth == RESPONSE_REQUEST_AUTH: - # MD5 authentication (backward compatibility) - perform_auth(sock, password, hashlib.md5, 32, "MD5") + + if auth in auth_methods: + hash_func, nonce_size, hash_name = auth_methods[auth] + perform_auth(sock, password, hash_func, nonce_size, hash_name) + elif auth != RESPONSE_AUTH_OK: + raise OTAError(f"Unknown authentication method requested: 0x{auth:02X}") # Set higher timeout during upload sock.settimeout(30.0) From 4b6fbc2a1e28b95d6272ac9a37dcaa5061833a4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 15:56:40 -0600 Subject: [PATCH 10/47] preen --- esphome/espota2.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index dc4fa7237b..5f906e4d08 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -269,12 +269,6 @@ def perform_ota( send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) - # Authentication method lookup table - auth_methods = { - RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), - RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), - } - (auth,) = receive_exactly( sock, 1, @@ -282,8 +276,8 @@ def perform_ota( [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], ) - if auth in auth_methods: - hash_func, nonce_size, hash_name = auth_methods[auth] + if auth in _AUTH_METHODS: + hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) elif auth != RESPONSE_AUTH_OK: raise OTAError(f"Unknown authentication method requested: 0x{auth:02X}") From e41ca7e888d3747ef1ae8dc0bcec74add66d3c10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 16:31:58 -0600 Subject: [PATCH 11/47] tidy --- .../components/esphome/ota/ota_esphome.cpp | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 8b6235e247..d638b030e9 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -105,18 +105,18 @@ static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; template struct HashTraits; template<> struct HashTraits { - static constexpr int nonce_size = 8; - static constexpr int hex_size = 32; - static constexpr const char *name = "MD5"; - static constexpr ota::OTAResponseTypes auth_request = ota::OTA_RESPONSE_REQUEST_AUTH; + static constexpr int NONCE_SIZE = 8; + static constexpr int HEX_SIZE = 32; + static constexpr const char *NAME = "MD5"; + static constexpr ota::OTAResponseTypes AUTH_REQUEST = ota::OTA_RESPONSE_REQUEST_AUTH; }; #ifdef USE_OTA_SHA256 template<> struct HashTraits { - static constexpr int nonce_size = 16; - static constexpr int hex_size = 64; - static constexpr const char *name = "SHA256"; - static constexpr ota::OTAResponseTypes auth_request = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; + static constexpr int NONCE_SIZE = 16; + static constexpr int HEX_SIZE = 64; + static constexpr const char *NAME = "SHA256"; + static constexpr ota::OTAResponseTypes AUTH_REQUEST = ota::OTA_RESPONSE_REQUEST_SHA256_AUTH; }; #endif @@ -125,7 +125,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co // Minimize stack usage by reusing buffers // We only need 2 buffers at most at the same time - constexpr size_t hex_buffer_size = Traits::hex_size + 1; + constexpr size_t hex_buffer_size = Traits::HEX_SIZE + 1; // These two buffers are reused throughout the function char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result @@ -136,7 +136,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) // Send auth request type - buf[0] = Traits::auth_request; + buf[0] = Traits::AUTH_REQUEST; ota->writeall_(buf, 1); HashClass hasher; @@ -150,7 +150,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co nonce_bytes[2] = (r1 >> 8) & 0xFF; nonce_bytes[3] = r1 & 0xFF; - if (Traits::nonce_size == 8) { + if (Traits::NONCE_SIZE == 8) { // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 hasher.add(nonce_bytes, 4); } @@ -169,50 +169,50 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co // Use hex_buffer1 for nonce hasher.get_hex(hex_buffer1); - hex_buffer1[Traits::hex_size] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::name, hex_buffer1); + hex_buffer1[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); // Send nonce - if (!ota->writeall_(reinterpret_cast(hex_buffer1), Traits::hex_size)) { - ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::name); + if (!ota->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::NAME); return false; } // Prepare challenge hasher.init(); hasher.add(password.c_str(), password.length()); - hasher.add(hex_buffer1, Traits::hex_size); // Add nonce + hasher.add(hex_buffer1, Traits::HEX_SIZE); // Add nonce // Receive cnonce into hex_buffer2 - if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::hex_size)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::name); + if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::NAME); return false; } - hex_buffer2[Traits::hex_size] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::name, hex_buffer2); + hex_buffer2[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); // Add cnonce to hash - hasher.add(hex_buffer2, Traits::hex_size); + hasher.add(hex_buffer2, Traits::HEX_SIZE); // Calculate result - reuse hex_buffer1 for expected hasher.calculate(); hasher.get_hex(hex_buffer1); - hex_buffer1[Traits::hex_size] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::name, hex_buffer1); + hex_buffer1[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::NAME, hex_buffer1); // Receive response - reuse hex_buffer2 - if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::hex_size)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::name); + if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::NAME); return false; } - hex_buffer2[Traits::hex_size] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::name, hex_buffer2); + hex_buffer2[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::NAME, hex_buffer2); // Compare - bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::hex_size) == 0; + bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::HEX_SIZE) == 0; if (!matches) { - ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::name); + ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::NAME); } return matches; From acb561633405ad3976bc425133f25bdaafbb496c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 17:00:03 -0600 Subject: [PATCH 12/47] make member --- esphome/components/esphome/ota/ota_esphome.cpp | 10 +++++----- esphome/components/esphome/ota/ota_esphome.h | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index d638b030e9..0eef0f0a57 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -120,7 +120,7 @@ template<> struct HashTraits { }; #endif -template bool perform_hash_auth(ESPHomeOTAComponent *ota, const std::string &password) { +template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &password) { using Traits = HashTraits; // Minimize stack usage by reusing buffers @@ -137,7 +137,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co // Send auth request type buf[0] = Traits::AUTH_REQUEST; - ota->writeall_(buf, 1); + this->writeall_(buf, 1); HashClass hasher; hasher.init(); @@ -173,7 +173,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); // Send nonce - if (!ota->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { + if (!this->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::NAME); return false; } @@ -184,7 +184,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co hasher.add(hex_buffer1, Traits::HEX_SIZE); // Add nonce // Receive cnonce into hex_buffer2 - if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::NAME); return false; } @@ -201,7 +201,7 @@ template bool perform_hash_auth(ESPHomeOTAComponent *ota, co ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::NAME, hex_buffer1); // Receive response - reuse hex_buffer2 - if (!ota->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::NAME); return false; } diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index f5a3e43ae3..8bfb1658b2 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -30,6 +30,7 @@ class ESPHomeOTAComponent : public ota::OTAComponent { protected: void handle_handshake_(); void handle_data_(); + template bool perform_hash_auth_(const std::string &password); bool readall_(uint8_t *buf, size_t len); bool writeall_(const uint8_t *buf, size_t len); void log_socket_error_(const LogString *msg); From 110b364c1f1d9eeb9d2dc5206a3b8b3d6e089802 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 17:00:23 -0600 Subject: [PATCH 13/47] make member --- esphome/components/esphome/ota/ota_esphome.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 0eef0f0a57..cf845dd5c6 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -355,12 +355,12 @@ void ESPHomeOTAComponent::handle_data_() { if (use_sha256) { // Use SHA256 for authentication - auth_success = perform_hash_auth(this, this->password_); + auth_success = this->perform_hash_auth_(this->password_); } else #endif // USE_OTA_SHA256 { // Fall back to MD5 for backward compatibility (or when SHA256 is not available) - auth_success = perform_hash_auth(this, this->password_); + auth_success = this->perform_hash_auth_(this->password_); } if (!auth_success) { From 6810e87fa7471027f02b3489ca10e95df5ec84ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 22:35:18 -0600 Subject: [PATCH 14/47] reorder --- .../components/esphome/ota/ota_esphome.cpp | 203 +++++++++--------- 1 file changed, 105 insertions(+), 98 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index cf845dd5c6..62cfe8d388 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -120,104 +120,6 @@ template<> struct HashTraits { }; #endif -template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &password) { - using Traits = HashTraits; - - // Minimize stack usage by reusing buffers - // We only need 2 buffers at most at the same time - constexpr size_t hex_buffer_size = Traits::HEX_SIZE + 1; - - // These two buffers are reused throughout the function - char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result - char hex_buffer2[hex_buffer_size]; // Used for: cnonce -> response - - // Small stack buffer for auth request and nonce seed bytes - uint8_t buf[1]; - uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) - - // Send auth request type - buf[0] = Traits::AUTH_REQUEST; - this->writeall_(buf, 1); - - HashClass hasher; - hasher.init(); - - // Generate nonce seed bytes - uint32_t r1 = random_uint32(); - // Convert first uint32 to bytes (always needed for MD5) - nonce_bytes[0] = (r1 >> 24) & 0xFF; - nonce_bytes[1] = (r1 >> 16) & 0xFF; - nonce_bytes[2] = (r1 >> 8) & 0xFF; - nonce_bytes[3] = r1 & 0xFF; - - if (Traits::NONCE_SIZE == 8) { - // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 - hasher.add(nonce_bytes, 4); - } -#ifdef USE_OTA_SHA256 - else { - // SHA256: 16 chars = "%08x%08x" format = 8 bytes from two random uint32s - uint32_t r2 = random_uint32(); - nonce_bytes[4] = (r2 >> 24) & 0xFF; - nonce_bytes[5] = (r2 >> 16) & 0xFF; - nonce_bytes[6] = (r2 >> 8) & 0xFF; - nonce_bytes[7] = r2 & 0xFF; - hasher.add(nonce_bytes, 8); - } -#endif - hasher.calculate(); - - // Use hex_buffer1 for nonce - hasher.get_hex(hex_buffer1); - hex_buffer1[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); - - // Send nonce - if (!this->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::NAME); - return false; - } - - // Prepare challenge - hasher.init(); - hasher.add(password.c_str(), password.length()); - hasher.add(hex_buffer1, Traits::HEX_SIZE); // Add nonce - - // Receive cnonce into hex_buffer2 - if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::NAME); - return false; - } - hex_buffer2[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); - - // Add cnonce to hash - hasher.add(hex_buffer2, Traits::HEX_SIZE); - - // Calculate result - reuse hex_buffer1 for expected - hasher.calculate(); - hasher.get_hex(hex_buffer1); - hex_buffer1[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::NAME, hex_buffer1); - - // Receive response - reuse hex_buffer2 - if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::NAME); - return false; - } - hex_buffer2[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::NAME, hex_buffer2); - - // Compare - bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::HEX_SIZE) == 0; - - if (!matches) { - ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::NAME); - } - - return matches; -} - void ESPHomeOTAComponent::handle_handshake_() { /// Handle the initial OTA handshake. /// @@ -587,5 +489,110 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() { delay(1); } +// Template function definition - placed at end to ensure all types are complete +template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &password) { + using Traits = HashTraits; + + // Minimize stack usage by reusing buffers + // We only need 2 buffers at most at the same time + constexpr size_t hex_buffer_size = Traits::HEX_SIZE + 1; + + // These two buffers are reused throughout the function + char hex_buffer1[hex_buffer_size]; // Used for: nonce -> expected result + char hex_buffer2[hex_buffer_size]; // Used for: cnonce -> response + + // Small stack buffer for auth request and nonce seed bytes + uint8_t buf[1]; + uint8_t nonce_bytes[8]; // Max 8 bytes (2 x uint32_t for SHA256) + + // Send auth request type + buf[0] = Traits::AUTH_REQUEST; + this->writeall_(buf, 1); + + HashClass hasher; + hasher.init(); + + // Generate nonce seed bytes + uint32_t r1 = random_uint32(); + // Convert first uint32 to bytes (always needed for MD5) + nonce_bytes[0] = (r1 >> 24) & 0xFF; + nonce_bytes[1] = (r1 >> 16) & 0xFF; + nonce_bytes[2] = (r1 >> 8) & 0xFF; + nonce_bytes[3] = r1 & 0xFF; + + if (Traits::NONCE_SIZE == 8) { + // MD5: 8 chars = "%08x" format = 4 bytes from one random uint32 + hasher.add(nonce_bytes, 4); + } +#ifdef USE_OTA_SHA256 + else { + // SHA256: 16 chars = "%08x%08x" format = 8 bytes from two random uint32s + uint32_t r2 = random_uint32(); + nonce_bytes[4] = (r2 >> 24) & 0xFF; + nonce_bytes[5] = (r2 >> 16) & 0xFF; + nonce_bytes[6] = (r2 >> 8) & 0xFF; + nonce_bytes[7] = r2 & 0xFF; + hasher.add(nonce_bytes, 8); + } +#endif + hasher.calculate(); + + // Use hex_buffer1 for nonce + hasher.get_hex(hex_buffer1); + hex_buffer1[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); + + // Send nonce + if (!this->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::NAME); + return false; + } + + // Prepare challenge + hasher.init(); + hasher.add(password.c_str(), password.length()); + hasher.add(hex_buffer1, Traits::HEX_SIZE); // Add nonce + + // Receive cnonce into hex_buffer2 + if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::NAME); + return false; + } + hex_buffer2[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); + + // Add cnonce to hash + hasher.add(hex_buffer2, Traits::HEX_SIZE); + + // Calculate result - reuse hex_buffer1 for expected + hasher.calculate(); + hasher.get_hex(hex_buffer1); + hex_buffer1[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::NAME, hex_buffer1); + + // Receive response - reuse hex_buffer2 + if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { + ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::NAME); + return false; + } + hex_buffer2[Traits::HEX_SIZE] = '\0'; + ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::NAME, hex_buffer2); + + // Compare + bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::HEX_SIZE) == 0; + + if (!matches) { + ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::NAME); + } + + return matches; +} + +// Explicit template instantiations +template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &); +#ifdef USE_OTA_SHA256 +template bool ESPHomeOTAComponent::perform_hash_auth_(const std::string &); +#endif + } // namespace esphome #endif From e49cbac46a627a5867f949a3334f745a5707f4cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 22:51:14 -0600 Subject: [PATCH 15/47] optimize --- esphome/components/sha256/sha256.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 699579251e..94f623f2fa 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -98,7 +98,9 @@ void SHA256::get_hex(char *output) { return; } for (size_t i = 0; i < 32; i++) { - sprintf(output + i * 2, "%02x", this->ctx_->hash[i]); + uint8_t byte = this->ctx_->hash[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); } } From dfc161b618eb7572a6abdab666ed88540e1c34cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Sep 2025 22:54:36 -0600 Subject: [PATCH 16/47] preen --- esphome/components/esphome/ota/ota_esphome.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 62cfe8d388..fc1db50c05 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -540,11 +540,11 @@ template bool ESPHomeOTAComponent::perform_hash_auth_(const // Use hex_buffer1 for nonce hasher.get_hex(hex_buffer1); hex_buffer1[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); + ESP_LOGV(TAG, "Auth: %s Nonce is %s", Traits::NAME, hex_buffer1); // Send nonce if (!this->writeall_(reinterpret_cast(hex_buffer1), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Writing %s nonce failed", Traits::NAME); + ESP_LOGW(TAG, "Auth: Writing %s nonce failed", Traits::NAME); return false; } @@ -555,11 +555,11 @@ template bool ESPHomeOTAComponent::perform_hash_auth_(const // Receive cnonce into hex_buffer2 if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s cnonce failed", Traits::NAME); + ESP_LOGW(TAG, "Auth: Reading %s cnonce failed", Traits::NAME); return false; } hex_buffer2[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); + ESP_LOGV(TAG, "Auth: %s CNonce is %s", Traits::NAME, hex_buffer2); // Add cnonce to hash hasher.add(hex_buffer2, Traits::HEX_SIZE); @@ -568,21 +568,21 @@ template bool ESPHomeOTAComponent::perform_hash_auth_(const hasher.calculate(); hasher.get_hex(hex_buffer1); hex_buffer1[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Result is %s", Traits::NAME, hex_buffer1); + ESP_LOGV(TAG, "Auth: %s Result is %s", Traits::NAME, hex_buffer1); // Receive response - reuse hex_buffer2 if (!this->readall_(reinterpret_cast(hex_buffer2), Traits::HEX_SIZE)) { - ESP_LOGW("esphome.ota", "Auth: Reading %s response failed", Traits::NAME); + ESP_LOGW(TAG, "Auth: Reading %s response failed", Traits::NAME); return false; } hex_buffer2[Traits::HEX_SIZE] = '\0'; - ESP_LOGV("esphome.ota", "Auth: %s Response is %s", Traits::NAME, hex_buffer2); + ESP_LOGV(TAG, "Auth: %s Response is %s", Traits::NAME, hex_buffer2); // Compare bool matches = memcmp(hex_buffer1, hex_buffer2, Traits::HEX_SIZE) == 0; if (!matches) { - ESP_LOGW("esphome.ota", "Auth failed! %s passwords do not match", Traits::NAME); + ESP_LOGW(TAG, "Auth failed! %s passwords do not match", Traits::NAME); } return matches; From f171afca62dd9f4fee81427df5b36c99103320d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Sep 2025 07:03:48 -0600 Subject: [PATCH 17/47] move context to .h --- esphome/components/sha256/sha256.cpp | 16 ---------------- esphome/components/sha256/sha256.h | 21 ++++++++++++++++++++- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 94f623f2fa..a3e06cae2f 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -2,19 +2,9 @@ #include "esphome/core/helpers.h" #include -#ifdef USE_ESP32 -#include "mbedtls/sha256.h" -#elif defined(USE_ARDUINO) -#include -#endif - namespace esphome::sha256 { #ifdef USE_ESP32 -struct SHA256::SHA256Context { - mbedtls_sha256_context ctx; - uint8_t hash[32]; -}; SHA256::~SHA256() { if (this->ctx_) { @@ -46,12 +36,6 @@ void SHA256::calculate() { #elif defined(USE_ARDUINO) -struct SHA256::SHA256Context { - ::SHA256 sha; - uint8_t hash[32]; - bool calculated{false}; -}; - SHA256::~SHA256() = default; void SHA256::init() { diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index dd1742ea0d..5917f68572 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -5,6 +5,12 @@ #include #include +#ifdef USE_ESP32 +#include "mbedtls/sha256.h" +#elif defined(USE_ARDUINO) +#include +#endif + namespace esphome::sha256 { class SHA256 { @@ -27,7 +33,20 @@ class SHA256 { bool equals_hex(const char *expected); protected: - struct SHA256Context; +#ifdef USE_ESP32 + struct SHA256Context { + mbedtls_sha256_context ctx; + uint8_t hash[32]; + }; +#elif defined(USE_ARDUINO) + struct SHA256Context { + ::SHA256 sha; + uint8_t hash[32]; + bool calculated{false}; + }; +#else +#error "SHA256 not supported on this platform" +#endif std::unique_ptr ctx_; }; From d7245ebde6370084d67064be5ddbe58586c84463 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Sep 2025 07:55:53 -0600 Subject: [PATCH 18/47] try to make it work on 8266 --- esphome/components/sha256/sha256.cpp | 29 ++++++++++++++++++++++++++++ esphome/components/sha256/sha256.h | 8 ++++++++ 2 files changed, 37 insertions(+) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index a3e06cae2f..b1a949b504 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -34,6 +34,35 @@ void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); } +#elif defined(USE_ESP8266) + +SHA256::~SHA256() = default; + +void SHA256::init() { + if (!this->ctx_) { + this->ctx_ = std::make_unique(); + } + br_sha256_init(&this->ctx_->ctx); + this->ctx_->calculated = false; +} + +void SHA256::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + br_sha256_update(&this->ctx_->ctx, data, len); +} + +void SHA256::calculate() { + if (!this->ctx_) { + this->init(); + } + if (!this->ctx_->calculated) { + br_sha256_out(&this->ctx_->ctx, this->ctx_->hash); + this->ctx_->calculated = true; + } +} + #elif defined(USE_ARDUINO) SHA256::~SHA256() = default; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 5917f68572..b047cb66e7 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -7,6 +7,8 @@ #ifdef USE_ESP32 #include "mbedtls/sha256.h" +#elif defined(USE_ESP8266) +#include #elif defined(USE_ARDUINO) #include #endif @@ -38,6 +40,12 @@ class SHA256 { mbedtls_sha256_context ctx; uint8_t hash[32]; }; +#elif defined(USE_ESP8266) + struct SHA256Context { + br_sha256_context ctx; + uint8_t hash[32]; + bool calculated{false}; + }; #elif defined(USE_ARDUINO) struct SHA256Context { ::SHA256 sha; From cebacfcc5931b3ab94e622671cad021602cb7bf9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Sep 2025 08:52:06 -0600 Subject: [PATCH 19/47] fix rp2040 --- esphome/components/sha256/sha256.cpp | 2 +- esphome/components/sha256/sha256.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index b1a949b504..cf3cfb1a30 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -34,7 +34,7 @@ void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_->ctx, this->ctx_->hash); } -#elif defined(USE_ESP8266) +#elif defined(USE_ESP8266) || defined(USE_RP2040) SHA256::~SHA256() = default; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index b047cb66e7..5f56d7542a 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -7,7 +7,7 @@ #ifdef USE_ESP32 #include "mbedtls/sha256.h" -#elif defined(USE_ESP8266) +#elif defined(USE_ESP8266) || defined(USE_RP2040) #include #elif defined(USE_ARDUINO) #include @@ -40,7 +40,7 @@ class SHA256 { mbedtls_sha256_context ctx; uint8_t hash[32]; }; -#elif defined(USE_ESP8266) +#elif defined(USE_ESP8266) || defined(USE_RP2040) struct SHA256Context { br_sha256_context ctx; uint8_t hash[32]; From 8da77059277c6f7859b41542acfb5dc49380e28a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 20 Sep 2025 18:29:17 -0600 Subject: [PATCH 20/47] fix nrf52 --- esphome/components/sha256/sha256.cpp | 6 ++++++ esphome/components/sha256/sha256.h | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index cf3cfb1a30..71e4045499 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -1,4 +1,8 @@ #include "sha256.h" + +// Only compile SHA256 implementation on platforms that support it +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) + #include "esphome/core/helpers.h" #include @@ -142,3 +146,5 @@ bool SHA256::equals_hex(const char *expected) { } } // namespace esphome::sha256 + +#endif // Platform check diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 5f56d7542a..2a7aa72183 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -1,6 +1,10 @@ #pragma once #include "esphome/core/defines.h" + +// Only define SHA256 on platforms that support it +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) + #include #include #include @@ -59,3 +63,5 @@ class SHA256 { }; } // namespace esphome::sha256 + +#endif // Platform check From a81985bfbaa515398e72a9d79435a6bf627e23a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:01:07 -0600 Subject: [PATCH 21/47] cleanup --- .../components/esphome/ota/ota_esphome.cpp | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fc1db50c05..0ce7f18f96 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -252,19 +252,40 @@ void ESPHomeOTAComponent::handle_data_() { bool auth_success = false; #ifdef USE_OTA_SHA256 - // Check if client supports SHA256 auth - bool use_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + // SECURITY HARDENING: Enforce SHA256 authentication on platforms that support it. + // + // This is a hardening measure to prevent future downgrade attacks where an attacker + // could force the use of MD5 authentication by manipulating the feature flags. + // + // While MD5 is currently still acceptable for our OTA authentication use case + // (where the password is a shared secret and we're only authenticating, not + // encrypting), at some point in the future MD5 will likely become so weak that + // it could be practically attacked. + // + // We enforce SHA256 now on capable platforms because: + // 1. We can't retroactively update device firmware in the field + // 2. Clients (like esphome CLI) can always be updated to support SHA256 + // 3. This prevents any possibility of downgrade attacks in the future + // + // Devices that don't support SHA256 (due to platform limitations) will + // continue to use MD5 as their only option (see #else branch below). - if (use_sha256) { - // Use SHA256 for authentication - auth_success = this->perform_hash_auth_(this->password_); - } else -#endif // USE_OTA_SHA256 - { - // Fall back to MD5 for backward compatibility (or when SHA256 is not available) - auth_success = this->perform_hash_auth_(this->password_); + bool client_supports_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; + + if (!client_supports_sha256) { + ESP_LOGW(TAG, "Client requires SHA256"); + error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) } + // Use SHA256 for authentication (mandatory on platforms that support it) + auth_success = this->perform_hash_auth_(this->password_); +#else + // Platform only supports MD5 - use it as the only available option + // This is not a security downgrade as the platform cannot support SHA256 + auth_success = this->perform_hash_auth_(this->password_); +#endif // USE_OTA_SHA256 + if (!auth_success) { error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; goto error; // NOLINT(cppcoreguidelines-avoid-goto) From 0ddd1037ca7fc6255cef82b5e6754cd73e961b0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:05:40 -0600 Subject: [PATCH 22/47] cleanup --- .../components/esphome/ota/ota_esphome.cpp | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 0ce7f18f96..8cd4152f6e 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -102,6 +102,12 @@ static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02; #endif +// Temporary flag to allow MD5 downgrade for ~3 versions (until 2026.1.0) +// This allows users to downgrade via OTA if they encounter issues after updating. +// Without this, users would need to do a serial flash to downgrade. +// TODO: Remove this flag and all associated code in 2026.1.0 +#define ALLOW_OTA_DOWNGRADE_MD5 + template struct HashTraits; template<> struct HashTraits { @@ -252,7 +258,7 @@ void ESPHomeOTAComponent::handle_data_() { bool auth_success = false; #ifdef USE_OTA_SHA256 - // SECURITY HARDENING: Enforce SHA256 authentication on platforms that support it. + // SECURITY HARDENING: Prefer SHA256 authentication on platforms that support it. // // This is a hardening measure to prevent future downgrade attacks where an attacker // could force the use of MD5 authentication by manipulating the feature flags. @@ -272,14 +278,25 @@ void ESPHomeOTAComponent::handle_data_() { bool client_supports_sha256 = (ota_features & FEATURE_SUPPORTS_SHA256_AUTH) != 0; +#ifdef ALLOW_OTA_DOWNGRADE_MD5 + // Temporary compatibility mode: Allow MD5 for ~3 versions to enable OTA downgrades + // This prevents users from being locked out if they need to downgrade after updating + // TODO: Remove this entire ifdef block in 2026.1.0 + if (client_supports_sha256) { + auth_success = this->perform_hash_auth_(this->password_); + } else { + ESP_LOGW(TAG, "Using MD5 auth for compatibility (deprecated)"); + auth_success = this->perform_hash_auth_(this->password_); + } +#else + // Strict mode: SHA256 required on capable platforms (future default) if (!client_supports_sha256) { ESP_LOGW(TAG, "Client requires SHA256"); error_code = ota::OTA_RESPONSE_ERROR_AUTH_INVALID; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - - // Use SHA256 for authentication (mandatory on platforms that support it) auth_success = this->perform_hash_auth_(this->password_); +#endif // ALLOW_OTA_DOWNGRADE_MD5 #else // Platform only supports MD5 - use it as the only available option // This is not a security downgrade as the platform cannot support SHA256 From 139577f96a174db845c53503a6729f62ee6adedb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:48:56 -0600 Subject: [PATCH 23/47] cleanup --- tests/unit_tests/test_espota2.py | 537 +++++++++++++++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 tests/unit_tests/test_espota2.py diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py new file mode 100644 index 0000000000..80fb804bc8 --- /dev/null +++ b/tests/unit_tests/test_espota2.py @@ -0,0 +1,537 @@ +"""Unit tests for esphome.espota2 module.""" + +from __future__ import annotations + +import gzip +import hashlib +import io +import socket +from unittest.mock import MagicMock, Mock, call, patch + +import pytest + +from esphome import espota2 +from esphome.core import EsphomeError + + +def test_recv_decode_with_decode() -> None: + """Test recv_decode with decode=True returns list.""" + mock_socket = Mock() + mock_socket.recv.return_value = b"\x01\x02\x03" + + result = espota2.recv_decode(mock_socket, 3, decode=True) + + assert result == [1, 2, 3] + mock_socket.recv.assert_called_once_with(3) + + +def test_recv_decode_without_decode() -> None: + """Test recv_decode with decode=False returns bytes.""" + mock_socket = Mock() + mock_socket.recv.return_value = b"\x01\x02\x03" + + result = espota2.recv_decode(mock_socket, 3, decode=False) + + assert result == b"\x01\x02\x03" + mock_socket.recv.assert_called_once_with(3) + + +def test_receive_exactly_success() -> None: + """Test receive_exactly successfully receives expected data.""" + mock_socket = Mock() + mock_socket.recv.side_effect = [b"\x00", b"\x01\x02"] + + result = espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) + + assert result == [0, 1, 2] + assert mock_socket.recv.call_count == 2 + + +def test_receive_exactly_with_error_response() -> None: + """Test receive_exactly raises OTAError on error response.""" + mock_socket = Mock() + mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]) + mock_socket.close = Mock() + + with pytest.raises(espota2.OTAError, match="Error auth:.*Authentication invalid"): + espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) + + mock_socket.close.assert_called_once() + + +def test_receive_exactly_socket_error() -> None: + """Test receive_exactly handles socket errors.""" + mock_socket = Mock() + mock_socket.recv.side_effect = OSError("Connection reset") + + with pytest.raises(espota2.OTAError, match="Error receiving acknowledge test"): + espota2.receive_exactly(mock_socket, 1, "test", espota2.RESPONSE_OK) + + +@pytest.mark.parametrize( + ("error_code", "expected_msg"), + [ + (espota2.RESPONSE_ERROR_MAGIC, "Error: Invalid magic byte"), + (espota2.RESPONSE_ERROR_UPDATE_PREPARE, "Error: Couldn't prepare flash memory"), + (espota2.RESPONSE_ERROR_AUTH_INVALID, "Error: Authentication invalid"), + ( + espota2.RESPONSE_ERROR_WRITING_FLASH, + "Error: Wring OTA data to flash memory failed", + ), + (espota2.RESPONSE_ERROR_UPDATE_END, "Error: Finishing update failed"), + ( + espota2.RESPONSE_ERROR_INVALID_BOOTSTRAPPING, + "Error: Please press the reset button", + ), + ( + espota2.RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG, + "Error: ESP has been flashed with wrong flash size", + ), + ( + espota2.RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG, + "Error: ESP does not have the requested flash size", + ), + ( + espota2.RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE, + "Error: ESP does not have enough space", + ), + ( + espota2.RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE, + "Error: The OTA partition on the ESP is too small", + ), + ( + espota2.RESPONSE_ERROR_NO_UPDATE_PARTITION, + "Error: The OTA partition on the ESP couldn't be found", + ), + (espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"), + (espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"), + ], +) +def test_check_error_with_various_errors(error_code: int, expected_msg: str) -> None: + """Test check_error raises appropriate errors for different error codes.""" + with pytest.raises(espota2.OTAError, match=expected_msg): + espota2.check_error([error_code], [espota2.RESPONSE_OK]) + + +def test_check_error_unexpected_response() -> None: + """Test check_error raises error for unexpected response.""" + with pytest.raises(espota2.OTAError, match="Unexpected response from ESP: 0x7F"): + espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK]) + + +def test_send_check_with_various_data_types() -> None: + """Test send_check handles different data types.""" + mock_socket = Mock() + + # Test with list/tuple + espota2.send_check(mock_socket, [0x01, 0x02], "list") + mock_socket.sendall.assert_called_with(b"\x01\x02") + + # Test with int + espota2.send_check(mock_socket, 0x42, "int") + mock_socket.sendall.assert_called_with(b"\x42") + + # Test with string + espota2.send_check(mock_socket, "hello", "string") + mock_socket.sendall.assert_called_with(b"hello") + + # Test with bytes (should pass through) + espota2.send_check(mock_socket, b"\xaa\xbb", "bytes") + mock_socket.sendall.assert_called_with(b"\xaa\xbb") + + +def test_send_check_socket_error() -> None: + """Test send_check handles socket errors.""" + mock_socket = Mock() + mock_socket.sendall.side_effect = OSError("Broken pipe") + + with pytest.raises(espota2.OTAError, match="Error sending test"): + espota2.send_check(mock_socket, b"data", "test") + + +def test_perform_ota_successful_md5_auth() -> None: + """Test successful OTA with MD5 authentication.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware content here") + + # Mock random for predictable cnonce + with ( + patch("random.random", return_value=0.123456), + patch("time.sleep"), + patch("time.perf_counter", side_effect=[0, 1]), + ): + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request + b"12345678901234567890123456789012", # 32 char hex nonce + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + # Run OTA + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) + + # Verify features were sent + assert mock_socket.sendall.call_args_list[1] == call( + bytes([espota2.FEATURE_SUPPORTS_COMPRESSION]) + ) + + # Verify cnonce was sent (MD5 of random.random()) + cnonce = hashlib.md5(b"0.123456").hexdigest() + assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) + + # Verify auth result was computed correctly + expected_hash = hashlib.md5() + expected_hash.update(b"testpass") + expected_hash.update(b"12345678901234567890123456789012") + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) + + +def test_perform_ota_no_auth() -> None: + """Test OTA without authentication.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware") + + with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_1_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # Should not send any auth-related data + auth_calls = [ + call + for call in mock_socket.sendall.call_args_list + if "cnonce" in str(call) or "result" in str(call) + ] + assert len(auth_calls) == 0 + + +def test_perform_ota_with_compression() -> None: + """Test OTA with compression support.""" + mock_socket = Mock() + original_content = b"firmware" * 100 # Repeating content for compression + mock_file = io.BytesIO(original_content) + + with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes( + [espota2.RESPONSE_SUPPORTS_COMPRESSION] + ), # Device supports compression + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # Verify compressed content was sent + # Get the binary size that was sent (4 bytes after features) + size_bytes = mock_socket.sendall.call_args_list[2][0][0] + sent_size = ( + (size_bytes[0] << 24) + | (size_bytes[1] << 16) + | (size_bytes[2] << 8) + | size_bytes[3] + ) + + # Size should be less than original due to compression + assert sent_size < len(original_content) + + # Verify the content sent was gzipped + compressed = gzip.compress(original_content, compresslevel=9) + assert sent_size == len(compressed) + + +def test_perform_ota_auth_without_password() -> None: + """Test OTA fails when auth is required but no password provided.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware") + + responses = [ + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_HEADER_OK]), + bytes([espota2.RESPONSE_REQUEST_AUTH]), + ] + + mock_socket.recv.side_effect = responses + + with pytest.raises( + espota2.OTAError, match="ESP requests password, but no password given" + ): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + +def test_perform_ota_unsupported_version() -> None: + """Test OTA fails with unsupported version.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware") + + responses = [ + bytes([espota2.RESPONSE_OK, 99]), # Unsupported version + ] + + mock_socket.recv.side_effect = responses + + with pytest.raises(espota2.OTAError, match="Device uses unsupported OTA version"): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + +def test_perform_ota_upload_error() -> None: + """Test OTA handles upload errors.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware") + + with patch("time.perf_counter", side_effect=[0, 1]): + # Setup responses - provide enough for the recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + # Add OSError to recv to simulate connection loss during chunk read + recv_responses.append(OSError("Connection lost")) + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises( + espota2.OTAError, match="Error receiving acknowledge chunk OK" + ): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + +def test_run_ota_impl_successful() -> None: + """Test run_ota_impl_ with successful upload.""" + mock_socket = Mock() + + with ( + patch("socket.socket", return_value=mock_socket), + patch("esphome.espota2.resolve_ip_address") as mock_resolve, + patch("builtins.open", create=True) as mock_open, + patch("esphome.espota2.perform_ota") as mock_perform, + ): + # Setup mocks + mock_resolve.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) + ] + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + + # Run OTA + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", "firmware.bin" + ) + + # Verify success + assert result_code == 0 + assert result_host == "192.168.1.100" + + # Verify socket was configured correctly + mock_socket.settimeout.assert_called_with(10.0) + mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) + mock_socket.close.assert_called_once() + + # Verify perform_ota was called + mock_perform.assert_called_once_with( + mock_socket, "password", mock_file, "firmware.bin" + ) + + +def test_run_ota_impl_connection_failed() -> None: + """Test run_ota_impl_ when connection fails.""" + mock_socket = Mock() + mock_socket.connect.side_effect = OSError("Connection refused") + + with ( + patch("socket.socket", return_value=mock_socket), + patch("esphome.espota2.resolve_ip_address") as mock_resolve, + ): + mock_resolve.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) + ] + + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", "firmware.bin" + ) + + assert result_code == 1 + assert result_host is None + mock_socket.close.assert_called_once() + + +def test_run_ota_impl_resolve_failed() -> None: + """Test run_ota_impl_ when DNS resolution fails.""" + with patch("esphome.espota2.resolve_ip_address") as mock_resolve: + mock_resolve.side_effect = EsphomeError("DNS resolution failed") + + with pytest.raises(espota2.OTAError, match="DNS resolution failed"): + result_code, result_host = espota2.run_ota_impl_( + "unknown.host", 3232, "password", "firmware.bin" + ) + + +def test_run_ota_wrapper() -> None: + """Test run_ota wrapper function.""" + with patch("esphome.espota2.run_ota_impl_") as mock_impl: + # Test successful case + mock_impl.return_value = (0, "192.168.1.100") + result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") + assert result == (0, "192.168.1.100") + + # Test error case + mock_impl.side_effect = espota2.OTAError("Test error") + result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") + assert result == (1, None) + + +def test_progress_bar() -> None: + """Test ProgressBar functionality.""" + with ( + patch("sys.stderr.write") as mock_write, + patch("sys.stderr.flush"), + ): + progress = espota2.ProgressBar() + + # Test initial update + progress.update(0.0) + assert mock_write.called + assert "0%" in mock_write.call_args[0][0] + + # Test progress update + mock_write.reset_mock() + progress.update(0.5) + assert "50%" in mock_write.call_args[0][0] + + # Test completion + mock_write.reset_mock() + progress.update(1.0) + assert "100%" in mock_write.call_args[0][0] + assert "Done" in mock_write.call_args[0][0] + + # Test done method + mock_write.reset_mock() + progress.done() + assert mock_write.call_args[0][0] == "\n" + + # Test same progress doesn't update + mock_write.reset_mock() + progress.update(0.5) + progress.update(0.5) + assert mock_write.call_count == 1 # Only called once + + +# Tests for SHA256 authentication (for when PR is merged) +def test_perform_ota_successful_sha256_auth() -> None: + """Test successful OTA with SHA256 authentication (future support).""" + mock_socket = Mock() + + # Mock random for predictable cnonce + with patch("random.random", return_value=0.123456): + # Constants for SHA256 auth (when implemented) + RESPONSE_REQUEST_SHA256_AUTH = 0x02 # From PR + + # Setup socket responses + responses = [ + # Version handshake + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + # Features response + bytes([espota2.RESPONSE_HEADER_OK]), + # SHA256 Auth request + bytes([RESPONSE_REQUEST_SHA256_AUTH]), + # Nonce from device (64 chars for SHA256) + b"1234567890123456789012345678901234567890123456789012345678901234", + # Auth result + bytes([espota2.RESPONSE_AUTH_OK]), + # Binary size OK + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), + # MD5 checksum OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), + # Chunk OK + bytes([espota2.RESPONSE_CHUNK_OK]), + bytes([espota2.RESPONSE_CHUNK_OK]), + bytes([espota2.RESPONSE_CHUNK_OK]), + # Receive OK + bytes([espota2.RESPONSE_RECEIVE_OK]), + # Update end OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), + ] + + mock_socket.recv.side_effect = responses + + # When SHA256 is implemented, this test will verify: + # 1. Client sends FEATURE_SUPPORTS_SHA256_AUTH flag + # 2. Device responds with RESPONSE_REQUEST_SHA256_AUTH + # 3. Authentication uses SHA256 instead of MD5 + # 4. Nonce is 64 characters instead of 32 + + # For now, this would raise an error since SHA256 isn't implemented + # Once implemented, uncomment to test: + # espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + + +def test_perform_ota_sha256_fallback_to_md5() -> None: + """Test SHA256-capable client falls back to MD5 for compatibility.""" + # This test verifies the temporary backward compatibility + # where a SHA256-capable client can still authenticate with MD5 + # This compatibility will be removed in 2026.1.0 according to PR + pass # Implementation depends on final PR merge + + +def test_perform_ota_version_differences() -> None: + """Test OTA behavior differences between version 1.0 and 2.0.""" + mock_socket = Mock() + mock_file = io.BytesIO(b"firmware") + + with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): + # Test version 1.0 - no chunk acknowledgments + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_1_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + # No RESPONSE_CHUNK_OK for v1 + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # Verify no chunk acknowledgments were expected + # (implementation detail - v1 doesn't wait for chunk OK) From 6c8b66df96146c5b1e5c30e337d9b02fb0aa337d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:52:39 -0600 Subject: [PATCH 24/47] cleanup --- tests/unit_tests/test_espota2.py | 64 ++++++++++++++++---------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 80fb804bc8..fd46ae3b49 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -14,9 +14,20 @@ from esphome import espota2 from esphome.core import EsphomeError -def test_recv_decode_with_decode() -> None: +@pytest.fixture +def mock_socket(): + """Create a mock socket for testing.""" + socket = Mock() + socket.close = Mock() + socket.recv = Mock() + socket.sendall = Mock() + socket.settimeout = Mock() + socket.connect = Mock() + return socket + + +def test_recv_decode_with_decode(mock_socket) -> None: """Test recv_decode with decode=True returns list.""" - mock_socket = Mock() mock_socket.recv.return_value = b"\x01\x02\x03" result = espota2.recv_decode(mock_socket, 3, decode=True) @@ -25,9 +36,8 @@ def test_recv_decode_with_decode() -> None: mock_socket.recv.assert_called_once_with(3) -def test_recv_decode_without_decode() -> None: +def test_recv_decode_without_decode(mock_socket) -> None: """Test recv_decode with decode=False returns bytes.""" - mock_socket = Mock() mock_socket.recv.return_value = b"\x01\x02\x03" result = espota2.recv_decode(mock_socket, 3, decode=False) @@ -36,9 +46,8 @@ def test_recv_decode_without_decode() -> None: mock_socket.recv.assert_called_once_with(3) -def test_receive_exactly_success() -> None: +def test_receive_exactly_success(mock_socket) -> None: """Test receive_exactly successfully receives expected data.""" - mock_socket = Mock() mock_socket.recv.side_effect = [b"\x00", b"\x01\x02"] result = espota2.receive_exactly(mock_socket, 3, "test", espota2.RESPONSE_OK) @@ -47,11 +56,9 @@ def test_receive_exactly_success() -> None: assert mock_socket.recv.call_count == 2 -def test_receive_exactly_with_error_response() -> None: +def test_receive_exactly_with_error_response(mock_socket) -> None: """Test receive_exactly raises OTAError on error response.""" - mock_socket = Mock() mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]) - mock_socket.close = Mock() with pytest.raises(espota2.OTAError, match="Error auth:.*Authentication invalid"): espota2.receive_exactly(mock_socket, 1, "auth", [espota2.RESPONSE_OK]) @@ -59,9 +66,8 @@ def test_receive_exactly_with_error_response() -> None: mock_socket.close.assert_called_once() -def test_receive_exactly_socket_error() -> None: +def test_receive_exactly_socket_error(mock_socket) -> None: """Test receive_exactly handles socket errors.""" - mock_socket = Mock() mock_socket.recv.side_effect = OSError("Connection reset") with pytest.raises(espota2.OTAError, match="Error receiving acknowledge test"): @@ -119,9 +125,8 @@ def test_check_error_unexpected_response() -> None: espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK]) -def test_send_check_with_various_data_types() -> None: +def test_send_check_with_various_data_types(mock_socket) -> None: """Test send_check handles different data types.""" - mock_socket = Mock() # Test with list/tuple espota2.send_check(mock_socket, [0x01, 0x02], "list") @@ -140,18 +145,16 @@ def test_send_check_with_various_data_types() -> None: mock_socket.sendall.assert_called_with(b"\xaa\xbb") -def test_send_check_socket_error() -> None: +def test_send_check_socket_error(mock_socket) -> None: """Test send_check handles socket errors.""" - mock_socket = Mock() mock_socket.sendall.side_effect = OSError("Broken pipe") with pytest.raises(espota2.OTAError, match="Error sending test"): espota2.send_check(mock_socket, b"data", "test") -def test_perform_ota_successful_md5_auth() -> None: +def test_perform_ota_successful_md5_auth(mock_socket) -> None: """Test successful OTA with MD5 authentication.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware content here") # Mock random for predictable cnonce @@ -183,9 +186,14 @@ def test_perform_ota_successful_md5_auth() -> None: # Verify magic bytes were sent assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify features were sent + # Verify features were sent (compression + SHA256 support) assert mock_socket.sendall.call_args_list[1] == call( - bytes([espota2.FEATURE_SUPPORTS_COMPRESSION]) + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] + ) ) # Verify cnonce was sent (MD5 of random.random()) @@ -201,9 +209,8 @@ def test_perform_ota_successful_md5_auth() -> None: assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_no_auth() -> None: +def test_perform_ota_no_auth(mock_socket) -> None: """Test OTA without authentication.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware") with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): @@ -231,9 +238,8 @@ def test_perform_ota_no_auth() -> None: assert len(auth_calls) == 0 -def test_perform_ota_with_compression() -> None: +def test_perform_ota_with_compression(mock_socket) -> None: """Test OTA with compression support.""" - mock_socket = Mock() original_content = b"firmware" * 100 # Repeating content for compression mock_file = io.BytesIO(original_content) @@ -274,9 +280,8 @@ def test_perform_ota_with_compression() -> None: assert sent_size == len(compressed) -def test_perform_ota_auth_without_password() -> None: +def test_perform_ota_auth_without_password(mock_socket) -> None: """Test OTA fails when auth is required but no password provided.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware") responses = [ @@ -293,9 +298,8 @@ def test_perform_ota_auth_without_password() -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_perform_ota_unsupported_version() -> None: +def test_perform_ota_unsupported_version(mock_socket) -> None: """Test OTA fails with unsupported version.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware") responses = [ @@ -308,9 +312,8 @@ def test_perform_ota_unsupported_version() -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_perform_ota_upload_error() -> None: +def test_perform_ota_upload_error(mock_socket) -> None: """Test OTA handles upload errors.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware") with patch("time.perf_counter", side_effect=[0, 1]): @@ -511,9 +514,8 @@ def test_perform_ota_sha256_fallback_to_md5() -> None: pass # Implementation depends on final PR merge -def test_perform_ota_version_differences() -> None: +def test_perform_ota_version_differences(mock_socket) -> None: """Test OTA behavior differences between version 1.0 and 2.0.""" - mock_socket = Mock() mock_file = io.BytesIO(b"firmware") with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): From 1d6c6c917af8ea98fafcb3f7c45c858dabf75082 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:54:17 -0600 Subject: [PATCH 25/47] cleanup --- tests/unit_tests/test_espota2.py | 61 +++++++++++++++++--------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index fd46ae3b49..119f024cbc 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -6,16 +6,21 @@ import gzip import hashlib import io import socket +from typing import TYPE_CHECKING from unittest.mock import MagicMock, Mock, call, patch import pytest +from pytest import CaptureFixture from esphome import espota2 from esphome.core import EsphomeError +if TYPE_CHECKING: + from unittest.mock import Mock as MockType + @pytest.fixture -def mock_socket(): +def mock_socket() -> MockType: """Create a mock socket for testing.""" socket = Mock() socket.close = Mock() @@ -421,40 +426,38 @@ def test_run_ota_wrapper() -> None: assert result == (1, None) -def test_progress_bar() -> None: +def test_progress_bar(capsys: CaptureFixture[str]) -> None: """Test ProgressBar functionality.""" - with ( - patch("sys.stderr.write") as mock_write, - patch("sys.stderr.flush"), - ): - progress = espota2.ProgressBar() + progress = espota2.ProgressBar() - # Test initial update - progress.update(0.0) - assert mock_write.called - assert "0%" in mock_write.call_args[0][0] + # Test initial update + progress.update(0.0) + captured = capsys.readouterr() + assert "0%" in captured.err + assert "[" in captured.err - # Test progress update - mock_write.reset_mock() - progress.update(0.5) - assert "50%" in mock_write.call_args[0][0] + # Test progress update + progress.update(0.5) + captured = capsys.readouterr() + assert "50%" in captured.err - # Test completion - mock_write.reset_mock() - progress.update(1.0) - assert "100%" in mock_write.call_args[0][0] - assert "Done" in mock_write.call_args[0][0] + # Test completion + progress.update(1.0) + captured = capsys.readouterr() + assert "100%" in captured.err + assert "Done" in captured.err - # Test done method - mock_write.reset_mock() - progress.done() - assert mock_write.call_args[0][0] == "\n" + # Test done method + progress.done() + captured = capsys.readouterr() + assert captured.err == "\n" - # Test same progress doesn't update - mock_write.reset_mock() - progress.update(0.5) - progress.update(0.5) - assert mock_write.call_count == 1 # Only called once + # Test same progress doesn't update + progress.update(0.5) + progress.update(0.5) + captured = capsys.readouterr() + # Should only see one update (second call shouldn't write) + assert captured.err.count("50%") == 1 # Tests for SHA256 authentication (for when PR is merged) From e2fd5190c2226cbfde398cead3865cf698ca4be3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:55:01 -0600 Subject: [PATCH 26/47] cleanup --- tests/unit_tests/test_espota2.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 119f024cbc..e1b3de6f97 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -31,6 +31,29 @@ def mock_socket() -> MockType: return socket +@pytest.fixture +def mock_file() -> io.BytesIO: + """Create a mock firmware file for testing.""" + return io.BytesIO(b"firmware content here") + + +@pytest.fixture +def mock_time(): + """Mock time-related functions for consistent testing.""" + with ( + patch("time.sleep"), + patch("time.perf_counter", side_effect=[0, 1]), + ) as mocks: + yield mocks + + +@pytest.fixture +def mock_random(): + """Mock random for predictable test values.""" + with patch("random.random", return_value=0.123456) as mock_rand: + yield mock_rand + + def test_recv_decode_with_decode(mock_socket) -> None: """Test recv_decode with decode=True returns list.""" mock_socket.recv.return_value = b"\x01\x02\x03" From 0b0eb5d4bf8447442b67672c38851d0337cac186 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:56:22 -0600 Subject: [PATCH 27/47] cleanup --- tests/unit_tests/test_espota2.py | 79 ++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 18 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index e1b3de6f97..6bfd20a0b2 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -54,6 +54,40 @@ def mock_random(): yield mock_rand +@pytest.fixture +def mock_resolve_ip(): + """Mock resolve_ip_address for testing.""" + with patch("esphome.espota2.resolve_ip_address") as mock: + mock.return_value = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) + ] + yield mock + + +@pytest.fixture +def mock_perform_ota(): + """Mock perform_ota function for testing.""" + with patch("esphome.espota2.perform_ota") as mock: + yield mock + + +@pytest.fixture +def mock_run_ota_impl(): + """Mock run_ota_impl_ function for testing.""" + with patch("esphome.espota2.run_ota_impl_") as mock: + mock.return_value = (0, "192.168.1.100") + yield mock + + +@pytest.fixture +def mock_open_file(): + """Mock file opening for testing.""" + with patch("builtins.open", create=True) as mock_open: + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + yield mock_open, mock_file + + def test_recv_decode_with_decode(mock_socket) -> None: """Test recv_decode with decode=True returns list.""" mock_socket.recv.return_value = b"\x01\x02\x03" @@ -365,26 +399,25 @@ def test_perform_ota_upload_error(mock_socket) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_run_ota_impl_successful() -> None: +def test_run_ota_impl_successful(mock_socket, tmp_path) -> None: """Test run_ota_impl_ with successful upload.""" - mock_socket = Mock() + # Create a real firmware file + firmware_file = tmp_path / "firmware.bin" + firmware_file.write_bytes(b"firmware content") with ( patch("socket.socket", return_value=mock_socket), patch("esphome.espota2.resolve_ip_address") as mock_resolve, - patch("builtins.open", create=True) as mock_open, patch("esphome.espota2.perform_ota") as mock_perform, ): # Setup mocks mock_resolve.return_value = [ (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) ] - mock_file = MagicMock() - mock_open.return_value.__enter__.return_value = mock_file - # Run OTA + # Run OTA with real file path result_code, result_host = espota2.run_ota_impl_( - "test.local", 3232, "password", "firmware.bin" + "test.local", 3232, "password", str(firmware_file) ) # Verify success @@ -396,17 +429,24 @@ def test_run_ota_impl_successful() -> None: mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) mock_socket.close.assert_called_once() - # Verify perform_ota was called - mock_perform.assert_called_once_with( - mock_socket, "password", mock_file, "firmware.bin" - ) + # Verify perform_ota was called with real file + mock_perform.assert_called_once() + call_args = mock_perform.call_args[0] + assert call_args[0] == mock_socket + assert call_args[1] == "password" + # The file object should be opened + assert hasattr(call_args[2], "read") + assert call_args[3] == str(firmware_file) -def test_run_ota_impl_connection_failed() -> None: +def test_run_ota_impl_connection_failed(mock_socket, tmp_path) -> None: """Test run_ota_impl_ when connection fails.""" - mock_socket = Mock() mock_socket.connect.side_effect = OSError("Connection refused") + # Create a real firmware file + firmware_file = tmp_path / "firmware.bin" + firmware_file.write_bytes(b"firmware content") + with ( patch("socket.socket", return_value=mock_socket), patch("esphome.espota2.resolve_ip_address") as mock_resolve, @@ -416,7 +456,7 @@ def test_run_ota_impl_connection_failed() -> None: ] result_code, result_host = espota2.run_ota_impl_( - "test.local", 3232, "password", "firmware.bin" + "test.local", 3232, "password", str(firmware_file) ) assert result_code == 1 @@ -424,14 +464,18 @@ def test_run_ota_impl_connection_failed() -> None: mock_socket.close.assert_called_once() -def test_run_ota_impl_resolve_failed() -> None: +def test_run_ota_impl_resolve_failed(tmp_path) -> None: """Test run_ota_impl_ when DNS resolution fails.""" + # Create a real firmware file + firmware_file = tmp_path / "firmware.bin" + firmware_file.write_bytes(b"firmware content") + with patch("esphome.espota2.resolve_ip_address") as mock_resolve: mock_resolve.side_effect = EsphomeError("DNS resolution failed") with pytest.raises(espota2.OTAError, match="DNS resolution failed"): result_code, result_host = espota2.run_ota_impl_( - "unknown.host", 3232, "password", "firmware.bin" + "unknown.host", 3232, "password", str(firmware_file) ) @@ -484,9 +528,8 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None: # Tests for SHA256 authentication (for when PR is merged) -def test_perform_ota_successful_sha256_auth() -> None: +def test_perform_ota_successful_sha256_auth(mock_socket) -> None: """Test successful OTA with SHA256 authentication (future support).""" - mock_socket = Mock() # Mock random for predictable cnonce with patch("random.random", return_value=0.123456): From 0d622fa268a0176158e50fa25172b0a2af3fdf3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 09:56:29 -0600 Subject: [PATCH 28/47] cleanup --- tests/unit_tests/test_espota2.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 6bfd20a0b2..5f9947afd7 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -5,6 +5,7 @@ from __future__ import annotations import gzip import hashlib import io +from pathlib import Path import socket from typing import TYPE_CHECKING from unittest.mock import MagicMock, Mock, call, patch @@ -399,7 +400,7 @@ def test_perform_ota_upload_error(mock_socket) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_run_ota_impl_successful(mock_socket, tmp_path) -> None: +def test_run_ota_impl_successful(mock_socket, tmp_path: Path) -> None: """Test run_ota_impl_ with successful upload.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" @@ -439,7 +440,7 @@ def test_run_ota_impl_successful(mock_socket, tmp_path) -> None: assert call_args[3] == str(firmware_file) -def test_run_ota_impl_connection_failed(mock_socket, tmp_path) -> None: +def test_run_ota_impl_connection_failed(mock_socket, tmp_path: Path) -> None: """Test run_ota_impl_ when connection fails.""" mock_socket.connect.side_effect = OSError("Connection refused") @@ -464,7 +465,7 @@ def test_run_ota_impl_connection_failed(mock_socket, tmp_path) -> None: mock_socket.close.assert_called_once() -def test_run_ota_impl_resolve_failed(tmp_path) -> None: +def test_run_ota_impl_resolve_failed(tmp_path: Path) -> None: """Test run_ota_impl_ when DNS resolution fails.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" From 594c60a4a46a75de312b8a4b026fb84937d7b75e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:07:01 -0600 Subject: [PATCH 29/47] preen --- tests/unit_tests/test_espota2.py | 459 ++++++++++++++++--------------- 1 file changed, 235 insertions(+), 224 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 5f9947afd7..cda5d8d222 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -216,131 +216,118 @@ def test_send_check_socket_error(mock_socket) -> None: espota2.send_check(mock_socket, b"data", "test") -def test_perform_ota_successful_md5_auth(mock_socket) -> None: +def test_perform_ota_successful_md5_auth( + mock_socket, mock_file, mock_time, mock_random +) -> None: """Test successful OTA with MD5 authentication.""" - mock_file = io.BytesIO(b"firmware content here") + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request + b"12345678901234567890123456789012", # 32 char hex nonce + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] - # Mock random for predictable cnonce - with ( - patch("random.random", return_value=0.123456), - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1]), - ): - # Setup socket responses for recv calls - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_2_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response - bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request - b"12345678901234567890123456789012", # 32 char hex nonce - bytes([espota2.RESPONSE_AUTH_OK]), # Auth result - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK - bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK - bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK - bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK - ] + mock_socket.recv.side_effect = recv_responses - mock_socket.recv.side_effect = recv_responses + # Run OTA + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") - # Run OTA - espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # Verify magic bytes were sent - assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - - # Verify features were sent (compression + SHA256 support) - assert mock_socket.sendall.call_args_list[1] == call( - bytes( - [ - espota2.FEATURE_SUPPORTS_COMPRESSION - | espota2.FEATURE_SUPPORTS_SHA256_AUTH - ] - ) + # Verify features were sent (compression + SHA256 support) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] ) + ) - # Verify cnonce was sent (MD5 of random.random()) - cnonce = hashlib.md5(b"0.123456").hexdigest() - assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) + # Verify cnonce was sent (MD5 of random.random()) + cnonce = hashlib.md5(b"0.123456").hexdigest() + assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) - # Verify auth result was computed correctly - expected_hash = hashlib.md5() - expected_hash.update(b"testpass") - expected_hash.update(b"12345678901234567890123456789012") - expected_hash.update(cnonce.encode()) - expected_result = expected_hash.hexdigest() - assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) + # Verify auth result was computed correctly + expected_hash = hashlib.md5() + expected_hash.update(b"testpass") + expected_hash.update(b"12345678901234567890123456789012") + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_no_auth(mock_socket) -> None: +def test_perform_ota_no_auth(mock_socket, mock_file, mock_time) -> None: """Test OTA without authentication.""" - mock_file = io.BytesIO(b"firmware") + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_1_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] - with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_1_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response - bytes([espota2.RESPONSE_AUTH_OK]), # No auth required - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK - bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK - bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK - ] + mock_socket.recv.side_effect = recv_responses - mock_socket.recv.side_effect = recv_responses + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - - # Should not send any auth-related data - auth_calls = [ - call - for call in mock_socket.sendall.call_args_list - if "cnonce" in str(call) or "result" in str(call) - ] - assert len(auth_calls) == 0 + # Should not send any auth-related data + auth_calls = [ + call + for call in mock_socket.sendall.call_args_list + if "cnonce" in str(call) or "result" in str(call) + ] + assert len(auth_calls) == 0 -def test_perform_ota_with_compression(mock_socket) -> None: +def test_perform_ota_with_compression(mock_socket, mock_time) -> None: """Test OTA with compression support.""" original_content = b"firmware" * 100 # Repeating content for compression mock_file = io.BytesIO(original_content) + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_SUPPORTS_COMPRESSION]), # Device supports compression + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] - with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_2_0]), # Version number - bytes( - [espota2.RESPONSE_SUPPORTS_COMPRESSION] - ), # Device supports compression - bytes([espota2.RESPONSE_AUTH_OK]), # No auth required - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK - bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK - bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK - bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK - ] + mock_socket.recv.side_effect = recv_responses - mock_socket.recv.side_effect = recv_responses + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + # Verify compressed content was sent + # Get the binary size that was sent (4 bytes after features) + size_bytes = mock_socket.sendall.call_args_list[2][0][0] + sent_size = ( + (size_bytes[0] << 24) + | (size_bytes[1] << 16) + | (size_bytes[2] << 8) + | size_bytes[3] + ) - # Verify compressed content was sent - # Get the binary size that was sent (4 bytes after features) - size_bytes = mock_socket.sendall.call_args_list[2][0][0] - sent_size = ( - (size_bytes[0] << 24) - | (size_bytes[1] << 16) - | (size_bytes[2] << 8) - | size_bytes[3] - ) + # Size should be less than original due to compression + assert sent_size < len(original_content) - # Size should be less than original due to compression - assert sent_size < len(original_content) - - # Verify the content sent was gzipped - compressed = gzip.compress(original_content, compresslevel=9) - assert sent_size == len(compressed) + # Verify the content sent was gzipped + compressed = gzip.compress(original_content, compresslevel=9) + assert sent_size == len(compressed) def test_perform_ota_auth_without_password(mock_socket) -> None: @@ -375,47 +362,35 @@ def test_perform_ota_unsupported_version(mock_socket) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_perform_ota_upload_error(mock_socket) -> None: +def test_perform_ota_upload_error(mock_socket, mock_file, mock_time) -> None: """Test OTA handles upload errors.""" - mock_file = io.BytesIO(b"firmware") + # Setup responses - provide enough for the recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + ] + # Add OSError to recv to simulate connection loss during chunk read + recv_responses.append(OSError("Connection lost")) - with patch("time.perf_counter", side_effect=[0, 1]): - # Setup responses - provide enough for the recv calls - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_2_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response - bytes([espota2.RESPONSE_AUTH_OK]), # No auth required - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK - ] - # Add OSError to recv to simulate connection loss during chunk read - recv_responses.append(OSError("Connection lost")) + mock_socket.recv.side_effect = recv_responses - mock_socket.recv.side_effect = recv_responses - - with pytest.raises( - espota2.OTAError, match="Error receiving acknowledge chunk OK" - ): - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + with pytest.raises(espota2.OTAError, match="Error receiving acknowledge chunk OK"): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_run_ota_impl_successful(mock_socket, tmp_path: Path) -> None: +def test_run_ota_impl_successful( + mock_socket, tmp_path: Path, mock_resolve_ip, mock_perform_ota +) -> None: """Test run_ota_impl_ with successful upload.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" firmware_file.write_bytes(b"firmware content") - with ( - patch("socket.socket", return_value=mock_socket), - patch("esphome.espota2.resolve_ip_address") as mock_resolve, - patch("esphome.espota2.perform_ota") as mock_perform, - ): - # Setup mocks - mock_resolve.return_value = [ - (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) - ] - + with patch("socket.socket", return_value=mock_socket): # Run OTA with real file path result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) @@ -431,8 +406,8 @@ def test_run_ota_impl_successful(mock_socket, tmp_path: Path) -> None: mock_socket.close.assert_called_once() # Verify perform_ota was called with real file - mock_perform.assert_called_once() - call_args = mock_perform.call_args[0] + mock_perform_ota.assert_called_once() + call_args = mock_perform_ota.call_args[0] assert call_args[0] == mock_socket assert call_args[1] == "password" # The file object should be opened @@ -440,7 +415,9 @@ def test_run_ota_impl_successful(mock_socket, tmp_path: Path) -> None: assert call_args[3] == str(firmware_file) -def test_run_ota_impl_connection_failed(mock_socket, tmp_path: Path) -> None: +def test_run_ota_impl_connection_failed( + mock_socket, tmp_path: Path, mock_resolve_ip +) -> None: """Test run_ota_impl_ when connection fails.""" mock_socket.connect.side_effect = OSError("Connection refused") @@ -448,14 +425,7 @@ def test_run_ota_impl_connection_failed(mock_socket, tmp_path: Path) -> None: firmware_file = tmp_path / "firmware.bin" firmware_file.write_bytes(b"firmware content") - with ( - patch("socket.socket", return_value=mock_socket), - patch("esphome.espota2.resolve_ip_address") as mock_resolve, - ): - mock_resolve.return_value = [ - (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.100", 3232)) - ] - + with patch("socket.socket", return_value=mock_socket): result_code, result_host = espota2.run_ota_impl_( "test.local", 3232, "password", str(firmware_file) ) @@ -465,33 +435,31 @@ def test_run_ota_impl_connection_failed(mock_socket, tmp_path: Path) -> None: mock_socket.close.assert_called_once() -def test_run_ota_impl_resolve_failed(tmp_path: Path) -> None: +def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip) -> None: """Test run_ota_impl_ when DNS resolution fails.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" firmware_file.write_bytes(b"firmware content") - with patch("esphome.espota2.resolve_ip_address") as mock_resolve: - mock_resolve.side_effect = EsphomeError("DNS resolution failed") + mock_resolve_ip.side_effect = EsphomeError("DNS resolution failed") - with pytest.raises(espota2.OTAError, match="DNS resolution failed"): - result_code, result_host = espota2.run_ota_impl_( - "unknown.host", 3232, "password", str(firmware_file) - ) + with pytest.raises(espota2.OTAError, match="DNS resolution failed"): + result_code, result_host = espota2.run_ota_impl_( + "unknown.host", 3232, "password", str(firmware_file) + ) -def test_run_ota_wrapper() -> None: +def test_run_ota_wrapper(mock_run_ota_impl) -> None: """Test run_ota wrapper function.""" - with patch("esphome.espota2.run_ota_impl_") as mock_impl: - # Test successful case - mock_impl.return_value = (0, "192.168.1.100") - result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") - assert result == (0, "192.168.1.100") + # Test successful case + mock_run_ota_impl.return_value = (0, "192.168.1.100") + result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") + assert result == (0, "192.168.1.100") - # Test error case - mock_impl.side_effect = espota2.OTAError("Test error") - result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") - assert result == (1, None) + # Test error case + mock_run_ota_impl.side_effect = espota2.OTAError("Test error") + result = espota2.run_ota("test.local", 3232, "pass", "fw.bin") + assert result == (1, None) def test_progress_bar(capsys: CaptureFixture[str]) -> None: @@ -528,82 +496,125 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None: assert captured.err.count("50%") == 1 -# Tests for SHA256 authentication (for when PR is merged) -def test_perform_ota_successful_sha256_auth(mock_socket) -> None: - """Test successful OTA with SHA256 authentication (future support).""" +# Tests for SHA256 authentication +def test_perform_ota_successful_sha256_auth( + mock_socket, mock_file, mock_time, mock_random +) -> None: + """Test successful OTA with SHA256 authentication.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), # SHA256 Auth request + b"1234567890123456789012345678901234567890123456789012345678901234", # 64 char hex nonce + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] - # Mock random for predictable cnonce - with patch("random.random", return_value=0.123456): - # Constants for SHA256 auth (when implemented) - RESPONSE_REQUEST_SHA256_AUTH = 0x02 # From PR + mock_socket.recv.side_effect = recv_responses - # Setup socket responses - responses = [ - # Version handshake - bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), - # Features response - bytes([espota2.RESPONSE_HEADER_OK]), - # SHA256 Auth request - bytes([RESPONSE_REQUEST_SHA256_AUTH]), - # Nonce from device (64 chars for SHA256) - b"1234567890123456789012345678901234567890123456789012345678901234", - # Auth result - bytes([espota2.RESPONSE_AUTH_OK]), - # Binary size OK - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), - # MD5 checksum OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), - # Chunk OK - bytes([espota2.RESPONSE_CHUNK_OK]), - bytes([espota2.RESPONSE_CHUNK_OK]), - bytes([espota2.RESPONSE_CHUNK_OK]), - # Receive OK - bytes([espota2.RESPONSE_RECEIVE_OK]), - # Update end OK - bytes([espota2.RESPONSE_UPDATE_END_OK]), - ] + # Run OTA + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") - mock_socket.recv.side_effect = responses + # Verify magic bytes were sent + assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES)) - # When SHA256 is implemented, this test will verify: - # 1. Client sends FEATURE_SUPPORTS_SHA256_AUTH flag - # 2. Device responds with RESPONSE_REQUEST_SHA256_AUTH - # 3. Authentication uses SHA256 instead of MD5 - # 4. Nonce is 64 characters instead of 32 + # Verify features were sent (compression + SHA256 support) + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] + ) + ) - # For now, this would raise an error since SHA256 isn't implemented - # Once implemented, uncomment to test: - # espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + # Verify cnonce was sent (SHA256 of random.random()) + cnonce = hashlib.sha256(b"0.123456").hexdigest() + assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) + + # Verify auth result was computed correctly with SHA256 + expected_hash = hashlib.sha256() + expected_hash.update(b"testpass") + expected_hash.update( + b"1234567890123456789012345678901234567890123456789012345678901234" + ) + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_sha256_fallback_to_md5() -> None: +def test_perform_ota_sha256_fallback_to_md5( + mock_socket, mock_file, mock_time, mock_random +) -> None: """Test SHA256-capable client falls back to MD5 for compatibility.""" # This test verifies the temporary backward compatibility # where a SHA256-capable client can still authenticate with MD5 - # This compatibility will be removed in 2026.1.0 according to PR - pass # Implementation depends on final PR merge + # This compatibility will be removed in 2026.1.0 + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes( + [espota2.RESPONSE_REQUEST_AUTH] + ), # MD5 Auth request (device doesn't support SHA256) + b"12345678901234567890123456789012", # 32 char hex nonce for MD5 + bytes([espota2.RESPONSE_AUTH_OK]), # Auth result + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses + + # Run OTA - should work even though device requested MD5 + espota2.perform_ota(mock_socket, "testpass", mock_file, "test.bin") + + # Verify client still advertised SHA256 support + assert mock_socket.sendall.call_args_list[1] == call( + bytes( + [ + espota2.FEATURE_SUPPORTS_COMPRESSION + | espota2.FEATURE_SUPPORTS_SHA256_AUTH + ] + ) + ) + + # But authentication was done with MD5 + cnonce = hashlib.md5(b"0.123456").hexdigest() + expected_hash = hashlib.md5() + expected_hash.update(b"testpass") + expected_hash.update(b"12345678901234567890123456789012") + expected_hash.update(cnonce.encode()) + expected_result = expected_hash.hexdigest() + assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_version_differences(mock_socket) -> None: +def test_perform_ota_version_differences(mock_socket, mock_file, mock_time) -> None: """Test OTA behavior differences between version 1.0 and 2.0.""" - mock_file = io.BytesIO(b"firmware") + # Test version 1.0 - no chunk acknowledgments + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_1_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + # No RESPONSE_CHUNK_OK for v1 + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] - with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): - # Test version 1.0 - no chunk acknowledgments - recv_responses = [ - bytes([espota2.RESPONSE_OK]), # First byte of version response - bytes([espota2.OTA_VERSION_1_0]), # Version number - bytes([espota2.RESPONSE_HEADER_OK]), # Features response - bytes([espota2.RESPONSE_AUTH_OK]), # No auth required - bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK - bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK - # No RESPONSE_CHUNK_OK for v1 - bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK - bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK - ] + mock_socket.recv.side_effect = recv_responses + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - mock_socket.recv.side_effect = recv_responses - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - - # Verify no chunk acknowledgments were expected - # (implementation detail - v1 doesn't wait for chunk OK) + # Verify no chunk acknowledgments were expected + # (implementation detail - v1 doesn't wait for chunk OK) + assert True # Placeholder assertion From 17704f712efd2ebce2178cae4901abaf44bb6527 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:07:27 -0600 Subject: [PATCH 30/47] preen --- tests/unit_tests/test_espota2.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index cda5d8d222..e76bc99211 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -41,11 +41,8 @@ def mock_file() -> io.BytesIO: @pytest.fixture def mock_time(): """Mock time-related functions for consistent testing.""" - with ( - patch("time.sleep"), - patch("time.perf_counter", side_effect=[0, 1]), - ) as mocks: - yield mocks + with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): + yield @pytest.fixture From eee8b111197f170144add82facca07187f55faa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:09:35 -0600 Subject: [PATCH 31/47] preen --- tests/unit_tests/test_espota2.py | 91 +++++++++++++++++--------------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index e76bc99211..60c34709d9 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -2,12 +2,13 @@ from __future__ import annotations +from collections.abc import Generator import gzip import hashlib import io from pathlib import Path import socket -from typing import TYPE_CHECKING +import struct from unittest.mock import MagicMock, Mock, call, patch import pytest @@ -16,20 +17,18 @@ from pytest import CaptureFixture from esphome import espota2 from esphome.core import EsphomeError -if TYPE_CHECKING: - from unittest.mock import Mock as MockType - @pytest.fixture -def mock_socket() -> MockType: +def mock_socket() -> Mock: """Create a mock socket for testing.""" - socket = Mock() - socket.close = Mock() - socket.recv = Mock() - socket.sendall = Mock() - socket.settimeout = Mock() - socket.connect = Mock() - return socket + socket_mock = Mock() + socket_mock.close = Mock() + socket_mock.recv = Mock() + socket_mock.sendall = Mock() + socket_mock.settimeout = Mock() + socket_mock.connect = Mock() + socket_mock.setsockopt = Mock() + return socket_mock @pytest.fixture @@ -39,21 +38,21 @@ def mock_file() -> io.BytesIO: @pytest.fixture -def mock_time(): +def mock_time() -> Generator[None]: """Mock time-related functions for consistent testing.""" with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): yield @pytest.fixture -def mock_random(): +def mock_random() -> Generator[Mock]: """Mock random for predictable test values.""" with patch("random.random", return_value=0.123456) as mock_rand: yield mock_rand @pytest.fixture -def mock_resolve_ip(): +def mock_resolve_ip() -> Generator[Mock]: """Mock resolve_ip_address for testing.""" with patch("esphome.espota2.resolve_ip_address") as mock: mock.return_value = [ @@ -63,14 +62,14 @@ def mock_resolve_ip(): @pytest.fixture -def mock_perform_ota(): +def mock_perform_ota() -> Generator[Mock]: """Mock perform_ota function for testing.""" with patch("esphome.espota2.perform_ota") as mock: yield mock @pytest.fixture -def mock_run_ota_impl(): +def mock_run_ota_impl() -> Generator[Mock]: """Mock run_ota_impl_ function for testing.""" with patch("esphome.espota2.run_ota_impl_") as mock: mock.return_value = (0, "192.168.1.100") @@ -78,7 +77,7 @@ def mock_run_ota_impl(): @pytest.fixture -def mock_open_file(): +def mock_open_file() -> Generator[tuple[Mock, MagicMock]]: """Mock file opening for testing.""" with patch("builtins.open", create=True) as mock_open: mock_file = MagicMock() @@ -86,7 +85,7 @@ def mock_open_file(): yield mock_open, mock_file -def test_recv_decode_with_decode(mock_socket) -> None: +def test_recv_decode_with_decode(mock_socket: Mock) -> None: """Test recv_decode with decode=True returns list.""" mock_socket.recv.return_value = b"\x01\x02\x03" @@ -96,7 +95,7 @@ def test_recv_decode_with_decode(mock_socket) -> None: mock_socket.recv.assert_called_once_with(3) -def test_recv_decode_without_decode(mock_socket) -> None: +def test_recv_decode_without_decode(mock_socket: Mock) -> None: """Test recv_decode with decode=False returns bytes.""" mock_socket.recv.return_value = b"\x01\x02\x03" @@ -106,7 +105,7 @@ def test_recv_decode_without_decode(mock_socket) -> None: mock_socket.recv.assert_called_once_with(3) -def test_receive_exactly_success(mock_socket) -> None: +def test_receive_exactly_success(mock_socket: Mock) -> None: """Test receive_exactly successfully receives expected data.""" mock_socket.recv.side_effect = [b"\x00", b"\x01\x02"] @@ -116,7 +115,7 @@ def test_receive_exactly_success(mock_socket) -> None: assert mock_socket.recv.call_count == 2 -def test_receive_exactly_with_error_response(mock_socket) -> None: +def test_receive_exactly_with_error_response(mock_socket: Mock) -> None: """Test receive_exactly raises OTAError on error response.""" mock_socket.recv.return_value = bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]) @@ -126,7 +125,7 @@ def test_receive_exactly_with_error_response(mock_socket) -> None: mock_socket.close.assert_called_once() -def test_receive_exactly_socket_error(mock_socket) -> None: +def test_receive_exactly_socket_error(mock_socket: Mock) -> None: """Test receive_exactly handles socket errors.""" mock_socket.recv.side_effect = OSError("Connection reset") @@ -185,7 +184,7 @@ def test_check_error_unexpected_response() -> None: espota2.check_error([0x7F], [espota2.RESPONSE_OK, espota2.RESPONSE_AUTH_OK]) -def test_send_check_with_various_data_types(mock_socket) -> None: +def test_send_check_with_various_data_types(mock_socket: Mock) -> None: """Test send_check handles different data types.""" # Test with list/tuple @@ -205,7 +204,7 @@ def test_send_check_with_various_data_types(mock_socket) -> None: mock_socket.sendall.assert_called_with(b"\xaa\xbb") -def test_send_check_socket_error(mock_socket) -> None: +def test_send_check_socket_error(mock_socket: Mock) -> None: """Test send_check handles socket errors.""" mock_socket.sendall.side_effect = OSError("Broken pipe") @@ -213,8 +212,9 @@ def test_send_check_socket_error(mock_socket) -> None: espota2.send_check(mock_socket, b"data", "test") +@pytest.mark.usefixtures("mock_time") def test_perform_ota_successful_md5_auth( - mock_socket, mock_file, mock_time, mock_random + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock ) -> None: """Test successful OTA with MD5 authentication.""" # Setup socket responses for recv calls @@ -263,7 +263,8 @@ def test_perform_ota_successful_md5_auth( assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_no_auth(mock_socket, mock_file, mock_time) -> None: +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: """Test OTA without authentication.""" recv_responses = [ bytes([espota2.RESPONSE_OK]), # First byte of version response @@ -289,7 +290,8 @@ def test_perform_ota_no_auth(mock_socket, mock_file, mock_time) -> None: assert len(auth_calls) == 0 -def test_perform_ota_with_compression(mock_socket, mock_time) -> None: +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_with_compression(mock_socket: Mock) -> None: """Test OTA with compression support.""" original_content = b"firmware" * 100 # Repeating content for compression mock_file = io.BytesIO(original_content) @@ -312,12 +314,7 @@ def test_perform_ota_with_compression(mock_socket, mock_time) -> None: # Verify compressed content was sent # Get the binary size that was sent (4 bytes after features) size_bytes = mock_socket.sendall.call_args_list[2][0][0] - sent_size = ( - (size_bytes[0] << 24) - | (size_bytes[1] << 16) - | (size_bytes[2] << 8) - | size_bytes[3] - ) + sent_size = struct.unpack(">I", size_bytes)[0] # Size should be less than original due to compression assert sent_size < len(original_content) @@ -327,7 +324,7 @@ def test_perform_ota_with_compression(mock_socket, mock_time) -> None: assert sent_size == len(compressed) -def test_perform_ota_auth_without_password(mock_socket) -> None: +def test_perform_ota_auth_without_password(mock_socket: Mock) -> None: """Test OTA fails when auth is required but no password provided.""" mock_file = io.BytesIO(b"firmware") @@ -345,7 +342,7 @@ def test_perform_ota_auth_without_password(mock_socket) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_perform_ota_unsupported_version(mock_socket) -> None: +def test_perform_ota_unsupported_version(mock_socket: Mock) -> None: """Test OTA fails with unsupported version.""" mock_file = io.BytesIO(b"firmware") @@ -359,7 +356,8 @@ def test_perform_ota_unsupported_version(mock_socket) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") -def test_perform_ota_upload_error(mock_socket, mock_file, mock_time) -> None: +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> None: """Test OTA handles upload errors.""" # Setup responses - provide enough for the recv calls recv_responses = [ @@ -380,7 +378,7 @@ def test_perform_ota_upload_error(mock_socket, mock_file, mock_time) -> None: def test_run_ota_impl_successful( - mock_socket, tmp_path: Path, mock_resolve_ip, mock_perform_ota + mock_socket: Mock, tmp_path: Path, mock_resolve_ip: Mock, mock_perform_ota: Mock ) -> None: """Test run_ota_impl_ with successful upload.""" # Create a real firmware file @@ -413,7 +411,7 @@ def test_run_ota_impl_successful( def test_run_ota_impl_connection_failed( - mock_socket, tmp_path: Path, mock_resolve_ip + mock_socket: Mock, tmp_path: Path, mock_resolve_ip: Mock ) -> None: """Test run_ota_impl_ when connection fails.""" mock_socket.connect.side_effect = OSError("Connection refused") @@ -432,7 +430,7 @@ def test_run_ota_impl_connection_failed( mock_socket.close.assert_called_once() -def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip) -> None: +def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: """Test run_ota_impl_ when DNS resolution fails.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" @@ -446,7 +444,7 @@ def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip) -> None: ) -def test_run_ota_wrapper(mock_run_ota_impl) -> None: +def test_run_ota_wrapper(mock_run_ota_impl: Mock) -> None: """Test run_ota wrapper function.""" # Test successful case mock_run_ota_impl.return_value = (0, "192.168.1.100") @@ -494,8 +492,9 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None: # Tests for SHA256 authentication +@pytest.mark.usefixtures("mock_time") def test_perform_ota_successful_sha256_auth( - mock_socket, mock_file, mock_time, mock_random + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock ) -> None: """Test successful OTA with SHA256 authentication.""" # Setup socket responses for recv calls @@ -546,8 +545,9 @@ def test_perform_ota_successful_sha256_auth( assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) +@pytest.mark.usefixtures("mock_time") def test_perform_ota_sha256_fallback_to_md5( - mock_socket, mock_file, mock_time, mock_random + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock ) -> None: """Test SHA256-capable client falls back to MD5 for compatibility.""" # This test verifies the temporary backward compatibility @@ -594,7 +594,10 @@ def test_perform_ota_sha256_fallback_to_md5( assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) -def test_perform_ota_version_differences(mock_socket, mock_file, mock_time) -> None: +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_version_differences( + mock_socket: Mock, mock_file: io.BytesIO +) -> None: """Test OTA behavior differences between version 1.0 and 2.0.""" # Test version 1.0 - no chunk acknowledgments recv_responses = [ From 0cae1f28b015b7d72736546e228575539ae151e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:12:48 -0600 Subject: [PATCH 32/47] preen --- tests/unit_tests/test_espota2.py | 65 ++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 60c34709d9..539f4ecc42 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -17,6 +17,12 @@ from pytest import CaptureFixture from esphome import espota2 from esphome.core import EsphomeError +# Test constants +MOCK_RANDOM_VALUE = 0.123456 +MOCK_RANDOM_BYTES = b"0.123456" +MOCK_MD5_NONCE = b"12345678901234567890123456789012" # 32 char nonce for MD5 +MOCK_SHA256_NONCE = b"1234567890123456789012345678901234567890123456789012345678901234" # 64 char nonce for SHA256 + @pytest.fixture def mock_socket() -> Mock: @@ -40,14 +46,18 @@ def mock_file() -> io.BytesIO: @pytest.fixture def mock_time() -> Generator[None]: """Mock time-related functions for consistent testing.""" - with patch("time.sleep"), patch("time.perf_counter", side_effect=[0, 1]): + # Provide enough values for multiple calls (tests may call perform_ota multiple times) + with ( + patch("time.sleep"), + patch("time.perf_counter", side_effect=[0, 1, 0, 1, 0, 1]), + ): yield @pytest.fixture def mock_random() -> Generator[Mock]: """Mock random for predictable test values.""" - with patch("random.random", return_value=0.123456) as mock_rand: + with patch("random.random", return_value=MOCK_RANDOM_VALUE) as mock_rand: yield mock_rand @@ -223,7 +233,7 @@ def test_perform_ota_successful_md5_auth( bytes([espota2.OTA_VERSION_2_0]), # Version number bytes([espota2.RESPONSE_HEADER_OK]), # Features response bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request - b"12345678901234567890123456789012", # 32 char hex nonce + MOCK_MD5_NONCE, # 32 char hex nonce bytes([espota2.RESPONSE_AUTH_OK]), # Auth result bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK @@ -251,13 +261,13 @@ def test_perform_ota_successful_md5_auth( ) # Verify cnonce was sent (MD5 of random.random()) - cnonce = hashlib.md5(b"0.123456").hexdigest() + cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) # Verify auth result was computed correctly expected_hash = hashlib.md5() expected_hash.update(b"testpass") - expected_hash.update(b"12345678901234567890123456789012") + expected_hash.update(MOCK_MD5_NONCE) expected_hash.update(cnonce.encode()) expected_result = expected_hash.hexdigest() assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) @@ -503,7 +513,7 @@ def test_perform_ota_successful_sha256_auth( bytes([espota2.OTA_VERSION_2_0]), # Version number bytes([espota2.RESPONSE_HEADER_OK]), # Features response bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), # SHA256 Auth request - b"1234567890123456789012345678901234567890123456789012345678901234", # 64 char hex nonce + MOCK_SHA256_NONCE, # 64 char hex nonce bytes([espota2.RESPONSE_AUTH_OK]), # Auth result bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK @@ -531,15 +541,13 @@ def test_perform_ota_successful_sha256_auth( ) # Verify cnonce was sent (SHA256 of random.random()) - cnonce = hashlib.sha256(b"0.123456").hexdigest() + cnonce = hashlib.sha256(MOCK_RANDOM_BYTES).hexdigest() assert mock_socket.sendall.call_args_list[2] == call(cnonce.encode()) # Verify auth result was computed correctly with SHA256 expected_hash = hashlib.sha256() expected_hash.update(b"testpass") - expected_hash.update( - b"1234567890123456789012345678901234567890123456789012345678901234" - ) + expected_hash.update(MOCK_SHA256_NONCE) expected_hash.update(cnonce.encode()) expected_result = expected_hash.hexdigest() assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) @@ -560,7 +568,7 @@ def test_perform_ota_sha256_fallback_to_md5( bytes( [espota2.RESPONSE_REQUEST_AUTH] ), # MD5 Auth request (device doesn't support SHA256) - b"12345678901234567890123456789012", # 32 char hex nonce for MD5 + MOCK_MD5_NONCE, # 32 char hex nonce for MD5 bytes([espota2.RESPONSE_AUTH_OK]), # Auth result bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK @@ -585,10 +593,10 @@ def test_perform_ota_sha256_fallback_to_md5( ) # But authentication was done with MD5 - cnonce = hashlib.md5(b"0.123456").hexdigest() + cnonce = hashlib.md5(MOCK_RANDOM_BYTES).hexdigest() expected_hash = hashlib.md5() expected_hash.update(b"testpass") - expected_hash.update(b"12345678901234567890123456789012") + expected_hash.update(MOCK_MD5_NONCE) expected_hash.update(cnonce.encode()) expected_result = expected_hash.hexdigest() assert mock_socket.sendall.call_args_list[3] == call(expected_result.encode()) @@ -615,6 +623,31 @@ def test_perform_ota_version_differences( mock_socket.recv.side_effect = recv_responses espota2.perform_ota(mock_socket, "", mock_file, "test.bin") - # Verify no chunk acknowledgments were expected - # (implementation detail - v1 doesn't wait for chunk OK) - assert True # Placeholder assertion + # For v1.0, verify that we only get the expected number of recv calls + # v1.0 doesn't have chunk acknowledgments, so fewer recv calls + assert mock_socket.recv.call_count == 8 # v1.0 has 8 recv calls + + # Reset mock for v2.0 test + mock_socket.reset_mock() + + # Reset file position for second test + mock_file.seek(0) + + # Test version 2.0 - with chunk acknowledgments + recv_responses_v2 = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_AUTH_OK]), # No auth required + bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK + bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK + bytes([espota2.RESPONSE_CHUNK_OK]), # v2.0 has chunk acknowledgment + bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK + bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK + ] + + mock_socket.recv.side_effect = recv_responses_v2 + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + # For v2.0, verify more recv calls due to chunk acknowledgments + assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK) From 2aa0ebd1d2d2daecf2697e58a2d06703eb0b1360 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:14:40 -0600 Subject: [PATCH 33/47] preen --- tests/unit_tests/test_espota2.py | 67 +++++++++++++++++--------------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 539f4ecc42..24b6a57b63 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -95,6 +95,13 @@ def mock_open_file() -> Generator[tuple[Mock, MagicMock]]: yield mock_open, mock_file +@pytest.fixture +def mock_socket_constructor(mock_socket: Mock) -> Generator[Mock]: + """Mock socket.socket constructor to return our mock socket.""" + with patch("socket.socket", return_value=mock_socket) as mock_constructor: + yield mock_constructor + + def test_recv_decode_with_decode(mock_socket: Mock) -> None: """Test recv_decode with decode=True returns list.""" mock_socket.recv.return_value = b"\x01\x02\x03" @@ -387,42 +394,41 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N espota2.perform_ota(mock_socket, "", mock_file, "test.bin") +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") def test_run_ota_impl_successful( - mock_socket: Mock, tmp_path: Path, mock_resolve_ip: Mock, mock_perform_ota: Mock + mock_socket: Mock, tmp_path: Path, mock_perform_ota: Mock ) -> None: """Test run_ota_impl_ with successful upload.""" # Create a real firmware file firmware_file = tmp_path / "firmware.bin" firmware_file.write_bytes(b"firmware content") - with patch("socket.socket", return_value=mock_socket): - # Run OTA with real file path - result_code, result_host = espota2.run_ota_impl_( - "test.local", 3232, "password", str(firmware_file) - ) + # Run OTA with real file path + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) - # Verify success - assert result_code == 0 - assert result_host == "192.168.1.100" + # Verify success + assert result_code == 0 + assert result_host == "192.168.1.100" - # Verify socket was configured correctly - mock_socket.settimeout.assert_called_with(10.0) - mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) - mock_socket.close.assert_called_once() + # Verify socket was configured correctly + mock_socket.settimeout.assert_called_with(10.0) + mock_socket.connect.assert_called_once_with(("192.168.1.100", 3232)) + mock_socket.close.assert_called_once() - # Verify perform_ota was called with real file - mock_perform_ota.assert_called_once() - call_args = mock_perform_ota.call_args[0] - assert call_args[0] == mock_socket - assert call_args[1] == "password" - # The file object should be opened - assert hasattr(call_args[2], "read") - assert call_args[3] == str(firmware_file) + # Verify perform_ota was called with real file + mock_perform_ota.assert_called_once() + call_args = mock_perform_ota.call_args[0] + assert call_args[0] == mock_socket + assert call_args[1] == "password" + # The file object should be opened + assert hasattr(call_args[2], "read") + assert call_args[3] == str(firmware_file) -def test_run_ota_impl_connection_failed( - mock_socket: Mock, tmp_path: Path, mock_resolve_ip: Mock -) -> None: +@pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") +def test_run_ota_impl_connection_failed(mock_socket: Mock, tmp_path: Path) -> None: """Test run_ota_impl_ when connection fails.""" mock_socket.connect.side_effect = OSError("Connection refused") @@ -430,14 +436,13 @@ def test_run_ota_impl_connection_failed( firmware_file = tmp_path / "firmware.bin" firmware_file.write_bytes(b"firmware content") - with patch("socket.socket", return_value=mock_socket): - result_code, result_host = espota2.run_ota_impl_( - "test.local", 3232, "password", str(firmware_file) - ) + result_code, result_host = espota2.run_ota_impl_( + "test.local", 3232, "password", str(firmware_file) + ) - assert result_code == 1 - assert result_host is None - mock_socket.close.assert_called_once() + assert result_code == 1 + assert result_host is None + mock_socket.close.assert_called_once() def test_run_ota_impl_resolve_failed(tmp_path: Path, mock_resolve_ip: Mock) -> None: From 69cad7b3c7fb1362fd4cb0d7fec5f74e15494067 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:15:22 -0600 Subject: [PATCH 34/47] preen --- tests/unit_tests/test_espota2.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 24b6a57b63..f7962ba5e7 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -9,7 +9,7 @@ import io from pathlib import Path import socket import struct -from unittest.mock import MagicMock, Mock, call, patch +from unittest.mock import Mock, call, patch import pytest from pytest import CaptureFixture @@ -86,15 +86,6 @@ def mock_run_ota_impl() -> Generator[Mock]: yield mock -@pytest.fixture -def mock_open_file() -> Generator[tuple[Mock, MagicMock]]: - """Mock file opening for testing.""" - with patch("builtins.open", create=True) as mock_open: - mock_file = MagicMock() - mock_open.return_value.__enter__.return_value = mock_file - yield mock_open, mock_file - - @pytest.fixture def mock_socket_constructor(mock_socket: Mock) -> Generator[Mock]: """Mock socket.socket constructor to return our mock socket.""" From 0e71662158124ba3457993378f3007d91e21c6a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:18:27 -0600 Subject: [PATCH 35/47] preen --- tests/unit_tests/test_espota2.py | 70 +++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f7962ba5e7..f74ca1e4e8 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -350,6 +350,72 @@ def test_perform_ota_auth_without_password(mock_socket: Mock) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_md5_auth_wrong_password( + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock +) -> None: + """Test OTA fails when MD5 authentication is rejected due to wrong password.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_AUTH]), # Auth request + MOCK_MD5_NONCE, # 32 char hex nonce + bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]), # Auth rejected! + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"): + espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin") + + # Verify the socket was closed after auth failure + mock_socket.close.assert_called() + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_sha256_auth_wrong_password( + mock_socket: Mock, mock_file: io.BytesIO, mock_random: Mock +) -> None: + """Test OTA fails when SHA256 authentication is rejected due to wrong password.""" + # Setup socket responses for recv calls + recv_responses = [ + bytes([espota2.RESPONSE_OK]), # First byte of version response + bytes([espota2.OTA_VERSION_2_0]), # Version number + bytes([espota2.RESPONSE_HEADER_OK]), # Features response + bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), # SHA256 Auth request + MOCK_SHA256_NONCE, # 64 char hex nonce + bytes([espota2.RESPONSE_ERROR_AUTH_INVALID]), # Auth rejected! + ] + + mock_socket.recv.side_effect = recv_responses + + with pytest.raises(espota2.OTAError, match="Error auth.*Authentication invalid"): + espota2.perform_ota(mock_socket, "wrongpassword", mock_file, "test.bin") + + # Verify the socket was closed after auth failure + mock_socket.close.assert_called() + + +def test_perform_ota_sha256_auth_without_password(mock_socket: Mock) -> None: + """Test OTA fails when SHA256 auth is required but no password provided.""" + mock_file = io.BytesIO(b"firmware") + + responses = [ + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_HEADER_OK]), + bytes([espota2.RESPONSE_REQUEST_SHA256_AUTH]), + ] + + mock_socket.recv.side_effect = responses + + with pytest.raises( + espota2.OTAError, match="ESP requests password, but no password given" + ): + espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + + def test_perform_ota_unsupported_version(mock_socket: Mock) -> None: """Test OTA fails with unsupported version.""" mock_file = io.BytesIO(b"firmware") @@ -413,8 +479,8 @@ def test_run_ota_impl_successful( call_args = mock_perform_ota.call_args[0] assert call_args[0] == mock_socket assert call_args[1] == "password" - # The file object should be opened - assert hasattr(call_args[2], "read") + # Verify the file object is a proper file handle + assert isinstance(call_args[2], io.IOBase) assert call_args[3] == str(firmware_file) From 97bc627d41241eb0063c12d8aab1c7772922f17d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:25:48 -0600 Subject: [PATCH 36/47] preen --- esphome/espota2.py | 26 ++++++++++++++++++-------- tests/unit_tests/test_espota2.py | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 5f906e4d08..36eb4d68ea 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -91,18 +91,26 @@ class OTAError(EsphomeError): pass -def recv_decode(sock, amount, decode=True): +def recv_decode( + sock: socket.socket, amount: int, decode: bool = True +) -> bytes | list[int]: data = sock.recv(amount) if not decode: return data return list(data) -def receive_exactly(sock, amount, msg, expect, decode=True): - data = [] if decode else b"" +def receive_exactly( + sock: socket.socket, + amount: int, + msg: str, + expect: int | list[int] | None, + decode: bool = True, +) -> list[int] | bytes: + data: list[int] | bytes = [] if decode else b"" try: - data += recv_decode(sock, 1, decode=decode) + data += recv_decode(sock, 1, decode=decode) # type: ignore[operator] except OSError as err: raise OTAError(f"Error receiving acknowledge {msg}: {err}") from err @@ -114,13 +122,13 @@ def receive_exactly(sock, amount, msg, expect, decode=True): while len(data) < amount: try: - data += recv_decode(sock, amount - len(data), decode=decode) + data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator] except OSError as err: raise OTAError(f"Error receiving {msg}: {err}") from err return data -def check_error(data, expect): +def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None: if not expect: return dat = data[0] @@ -187,7 +195,9 @@ def check_error(data, expect): raise OTAError(f"Unexpected response from ESP: 0x{data[0]:02X}") -def send_check(sock, data, msg): +def send_check( + sock: socket.socket, data: list[int] | tuple[int, ...] | int | str | bytes, msg: str +) -> None: try: if isinstance(data, (list, tuple)): data = bytes(data) @@ -239,7 +249,7 @@ def perform_ota( def perform_auth( sock: socket.socket, password: str, - hash_func: Any, + hash_func: Callable[[], Any], nonce_size: int, hash_name: str, ) -> None: diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f74ca1e4e8..c036a5de8e 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -416,6 +416,29 @@ def test_perform_ota_sha256_auth_without_password(mock_socket: Mock) -> None: espota2.perform_ota(mock_socket, "", mock_file, "test.bin") +def test_perform_ota_unexpected_auth_response(mock_socket: Mock) -> None: + """Test OTA fails when device sends an unexpected auth response.""" + mock_file = io.BytesIO(b"firmware") + + # Use 0x03 which is not in the expected auth responses + # This will be caught by check_error and raise "Unexpected response from ESP" + UNKNOWN_AUTH_METHOD = 0x03 + + responses = [ + bytes([espota2.RESPONSE_OK, espota2.OTA_VERSION_2_0]), + bytes([espota2.RESPONSE_HEADER_OK]), + bytes([UNKNOWN_AUTH_METHOD]), # Unknown auth method + ] + + mock_socket.recv.side_effect = responses + + # This will actually raise "Unexpected response from ESP" from check_error + with pytest.raises( + espota2.OTAError, match=r"Error auth: Unexpected response from ESP: 0x03" + ): + espota2.perform_ota(mock_socket, "password", mock_file, "test.bin") + + def test_perform_ota_unsupported_version(mock_socket: Mock) -> None: """Test OTA fails with unsupported version.""" mock_file = io.BytesIO(b"firmware") From 7d4a7d48ee9525b1875632af7f1541f070b5cdb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:26:52 -0600 Subject: [PATCH 37/47] remove unreachable code --- esphome/espota2.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 36eb4d68ea..ba47e6af0d 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -286,11 +286,8 @@ def perform_ota( [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], ) - if auth in _AUTH_METHODS: - hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] - perform_auth(sock, password, hash_func, nonce_size, hash_name) - elif auth != RESPONSE_AUTH_OK: - raise OTAError(f"Unknown authentication method requested: 0x{auth:02X}") + hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] + perform_auth(sock, password, hash_func, nonce_size, hash_name) # Set higher timeout during upload sock.settimeout(30.0) From 233cc08dc62ad0fd2ee92ac7c0777374d530a5b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:27:27 -0600 Subject: [PATCH 38/47] remove unreachable code --- esphome/espota2.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index ba47e6af0d..6e8936668b 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -286,8 +286,9 @@ def perform_ota( [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], ) - hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] - perform_auth(sock, password, hash_func, nonce_size, hash_name) + if auth != RESPONSE_AUTH_OK: + hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] + perform_auth(sock, password, hash_func, nonce_size, hash_name) # Set higher timeout during upload sock.settimeout(30.0) From e47cecc5f0240c52c8111e6b58c076d0df111c57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:28:09 -0600 Subject: [PATCH 39/47] remove unreachable code --- esphome/espota2.py | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index 6e8936668b..2a4d21dc3e 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -57,7 +57,7 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 _LOGGER = logging.getLogger(__name__) # Authentication method lookup table: response -> (hash_func, nonce_size, name) -_AUTH_METHODS: dict[int, tuple[Callable[[], Any], int, str]] = { +_AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } @@ -94,6 +94,13 @@ class OTAError(EsphomeError): def recv_decode( sock: socket.socket, amount: int, decode: bool = True ) -> bytes | list[int]: + """Receive data from socket and optionally decode to list of integers. + + :param sock: Socket to receive data from. + :param amount: Number of bytes to receive. + :param decode: If True, convert bytes to list of integers, otherwise return raw bytes. + :return: List of integers if decode=True, otherwise raw bytes. + """ data = sock.recv(amount) if not decode: return data @@ -107,6 +114,16 @@ def receive_exactly( expect: int | list[int] | None, decode: bool = True, ) -> list[int] | bytes: + """Receive exactly the specified amount of data from socket with error checking. + + :param sock: Socket to receive data from. + :param amount: Exact number of bytes to receive. + :param msg: Description of what is being received for error messages. + :param expect: Expected response code(s) for validation, None to skip validation. + :param decode: If True, return list of integers, otherwise return raw bytes. + :return: List of integers if decode=True, otherwise raw bytes. + :raises OTAError: If receiving fails or response doesn't match expected. + """ data: list[int] | bytes = [] if decode else b"" try: @@ -129,6 +146,12 @@ def receive_exactly( def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None: + """Check response data for error codes and validate against expected response. + + :param data: Response data from device (first byte is the response code). + :param expect: Expected response code(s), None to skip validation. + :raises OTAError: If an error code is detected or response doesn't match expected. + """ if not expect: return dat = data[0] @@ -198,6 +221,13 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None def send_check( sock: socket.socket, data: list[int] | tuple[int, ...] | int | str | bytes, msg: str ) -> None: + """Send data to socket with error handling. + + :param sock: Socket to send data to. + :param data: Data to send (can be list/tuple of ints, single int, string, or bytes). + :param msg: Description of what is being sent for error messages. + :raises OTAError: If sending fails. + """ try: if isinstance(data, (list, tuple)): data = bytes(data) @@ -249,7 +279,7 @@ def perform_ota( def perform_auth( sock: socket.socket, password: str, - hash_func: Callable[[], Any], + hash_func: Callable[..., Any], nonce_size: int, hash_name: str, ) -> None: @@ -257,9 +287,11 @@ def perform_ota( if not password: raise OTAError("ESP requests password, but no password given!") - nonce = receive_exactly( + nonce_bytes = receive_exactly( sock, nonce_size, f"{hash_name} authentication nonce", [], decode=False - ).decode() + ) + assert isinstance(nonce_bytes, bytes) + nonce = nonce_bytes.decode() _LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce) # Generate cnonce From 113fe6dfd5aed093118892fb5ce087e21ed759bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:35:20 -0600 Subject: [PATCH 40/47] sha256 for host --- esphome/components/sha256/__init__.py | 14 +++++++++ esphome/components/sha256/sha256.cpp | 31 ++++++++++++++++++- esphome/components/sha256/sha256.h | 10 +++++- tests/components/sha512/common.yaml | 5 +++ tests/components/sha512/test.bk72xx-ard.yaml | 2 ++ tests/components/sha512/test.esp32-idf.yaml | 1 + tests/components/sha512/test.esp8266-ard.yaml | 1 + tests/components/sha512/test.host.yaml | 1 + tests/components/sha512/test.rp2040-ard.yaml | 1 + 9 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/components/sha512/common.yaml create mode 100644 tests/components/sha512/test.bk72xx-ard.yaml create mode 100644 tests/components/sha512/test.esp32-idf.yaml create mode 100644 tests/components/sha512/test.esp8266-ard.yaml create mode 100644 tests/components/sha512/test.host.yaml create mode 100644 tests/components/sha512/test.rp2040-ard.yaml diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index e24da86e25..f77820744b 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -1,5 +1,19 @@ import esphome.codegen as cg +from esphome.core import CORE, IS_MACOS CODEOWNERS = ["@esphome/core"] sha256_ns = cg.esphome_ns.namespace("sha256") + + +async def to_code(config): + # Add OpenSSL library for host platform + if CORE.is_host: + if IS_MACOS: + # macOS needs special handling for Homebrew OpenSSL + cg.add_build_flag("-I/opt/homebrew/opt/openssl/include") + cg.add_build_flag("-L/opt/homebrew/opt/openssl/lib") + cg.add_build_flag("-lcrypto") + else: + # Linux and other Unix systems usually have OpenSSL in standard paths + cg.add_build_flag("-lcrypto") diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 71e4045499..6fa17bb7c0 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -1,7 +1,7 @@ #include "sha256.h" // Only compile SHA256 implementation on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) #include "esphome/core/helpers.h" #include @@ -67,6 +67,35 @@ void SHA256::calculate() { } } +#elif defined(USE_HOST) + +SHA256::~SHA256() = default; + +void SHA256::init() { + if (!this->ctx_) { + this->ctx_ = std::make_unique(); + } + SHA256_Init(&this->ctx_->ctx); + this->ctx_->calculated = false; +} + +void SHA256::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + SHA256_Update(&this->ctx_->ctx, data, len); +} + +void SHA256::calculate() { + if (!this->ctx_) { + this->init(); + } + if (!this->ctx_->calculated) { + SHA256_Final(this->ctx_->hash, &this->ctx_->ctx); + this->ctx_->calculated = true; + } +} + #elif defined(USE_ARDUINO) SHA256::~SHA256() = default; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 2a7aa72183..246a7ca891 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -3,7 +3,7 @@ #include "esphome/core/defines.h" // Only define SHA256 on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) #include #include @@ -13,6 +13,8 @@ #include "mbedtls/sha256.h" #elif defined(USE_ESP8266) || defined(USE_RP2040) #include +#elif defined(USE_HOST) +#include #elif defined(USE_ARDUINO) #include #endif @@ -50,6 +52,12 @@ class SHA256 { uint8_t hash[32]; bool calculated{false}; }; +#elif defined(USE_HOST) + struct SHA256Context { + SHA256_CTX ctx; + uint8_t hash[32]; + bool calculated{false}; + }; #elif defined(USE_ARDUINO) struct SHA256Context { ::SHA256 sha; diff --git a/tests/components/sha512/common.yaml b/tests/components/sha512/common.yaml new file mode 100644 index 0000000000..72adf30501 --- /dev/null +++ b/tests/components/sha512/common.yaml @@ -0,0 +1,5 @@ +wifi: + ssid: MySSID + password: password1 + +sha256: diff --git a/tests/components/sha512/test.bk72xx-ard.yaml b/tests/components/sha512/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/sha512/test.bk72xx-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/sha512/test.esp32-idf.yaml b/tests/components/sha512/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha512/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha512/test.esp8266-ard.yaml b/tests/components/sha512/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha512/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha512/test.host.yaml b/tests/components/sha512/test.host.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha512/test.host.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha512/test.rp2040-ard.yaml b/tests/components/sha512/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha512/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 93c444ee1520101f6712aa17707ed8a138c9bc57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:37:44 -0600 Subject: [PATCH 41/47] sha256 for host --- esphome/components/sha256/__init__.py | 6 +++++- tests/components/sha512/common.yaml | 4 ---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index f77820744b..045cbbe37e 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -1,10 +1,14 @@ import esphome.codegen as cg -from esphome.core import CORE, IS_MACOS +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.helpers import IS_MACOS CODEOWNERS = ["@esphome/core"] sha256_ns = cg.esphome_ns.namespace("sha256") +CONFIG_SCHEMA = cv.Schema({}) + async def to_code(config): # Add OpenSSL library for host platform diff --git a/tests/components/sha512/common.yaml b/tests/components/sha512/common.yaml index 72adf30501..2f254dbfc4 100644 --- a/tests/components/sha512/common.yaml +++ b/tests/components/sha512/common.yaml @@ -1,5 +1 @@ -wifi: - ssid: MySSID - password: password1 - sha256: From 4cdeb3f5470ac552dcc0b00fc1aea1a6dd4922dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:40:26 -0600 Subject: [PATCH 42/47] sha256 for host --- esphome/components/sha256/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index 045cbbe37e..91d4929a4f 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import IS_MACOS +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -10,7 +11,7 @@ sha256_ns = cg.esphome_ns.namespace("sha256") CONFIG_SCHEMA = cv.Schema({}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Add OpenSSL library for host platform if CORE.is_host: if IS_MACOS: From ee7e30eaa84f5cd927c8b807a1c7fb24c697b191 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:41:44 -0600 Subject: [PATCH 43/47] doh --- tests/components/sha256/common.yaml | 32 +++++++++++++++++++ .../test.bk72xx-ard.yaml} | 0 .../test.esp32-ard.yaml} | 0 .../test.esp32-idf.yaml} | 0 .../test.esp8266-ard.yaml} | 0 tests/components/sha256/test.host.yaml | 6 ++++ tests/components/sha256/test.rp2040-ard.yaml | 1 + tests/components/sha512/common.yaml | 1 - tests/components/sha512/test.bk72xx-ard.yaml | 2 -- 9 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 tests/components/sha256/common.yaml rename tests/components/{sha512/test.esp32-idf.yaml => sha256/test.bk72xx-ard.yaml} (100%) rename tests/components/{sha512/test.esp8266-ard.yaml => sha256/test.esp32-ard.yaml} (100%) rename tests/components/{sha512/test.host.yaml => sha256/test.esp32-idf.yaml} (100%) rename tests/components/{sha512/test.rp2040-ard.yaml => sha256/test.esp8266-ard.yaml} (100%) create mode 100644 tests/components/sha256/test.host.yaml create mode 100644 tests/components/sha256/test.rp2040-ard.yaml delete mode 100644 tests/components/sha512/common.yaml delete mode 100644 tests/components/sha512/test.bk72xx-ard.yaml diff --git a/tests/components/sha256/common.yaml b/tests/components/sha256/common.yaml new file mode 100644 index 0000000000..fa884c1958 --- /dev/null +++ b/tests/components/sha256/common.yaml @@ -0,0 +1,32 @@ +esphome: + on_boot: + - lambda: |- + // Test SHA256 functionality + #ifdef USE_SHA256 + using esphome::sha256::SHA256; + SHA256 hasher; + hasher.init(); + + // Test with "Hello World" - known SHA256 + const char* test_string = "Hello World"; + hasher.add(test_string, strlen(test_string)); + hasher.calculate(); + + char hex_output[65]; + hasher.get_hex(hex_output); + hex_output[64] = '\0'; + + ESP_LOGD("SHA256", "SHA256('Hello World') = %s", hex_output); + + // Expected: a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e + const char* expected = "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e"; + if (strcmp(hex_output, expected) == 0) { + ESP_LOGI("SHA256", "Test PASSED"); + } else { + ESP_LOGE("SHA256", "Test FAILED. Expected %s", expected); + } + #else + ESP_LOGW("SHA256", "SHA256 not available on this platform"); + #endif + +sha256: diff --git a/tests/components/sha512/test.esp32-idf.yaml b/tests/components/sha256/test.bk72xx-ard.yaml similarity index 100% rename from tests/components/sha512/test.esp32-idf.yaml rename to tests/components/sha256/test.bk72xx-ard.yaml diff --git a/tests/components/sha512/test.esp8266-ard.yaml b/tests/components/sha256/test.esp32-ard.yaml similarity index 100% rename from tests/components/sha512/test.esp8266-ard.yaml rename to tests/components/sha256/test.esp32-ard.yaml diff --git a/tests/components/sha512/test.host.yaml b/tests/components/sha256/test.esp32-idf.yaml similarity index 100% rename from tests/components/sha512/test.host.yaml rename to tests/components/sha256/test.esp32-idf.yaml diff --git a/tests/components/sha512/test.rp2040-ard.yaml b/tests/components/sha256/test.esp8266-ard.yaml similarity index 100% rename from tests/components/sha512/test.rp2040-ard.yaml rename to tests/components/sha256/test.esp8266-ard.yaml diff --git a/tests/components/sha256/test.host.yaml b/tests/components/sha256/test.host.yaml new file mode 100644 index 0000000000..1f50d9ea38 --- /dev/null +++ b/tests/components/sha256/test.host.yaml @@ -0,0 +1,6 @@ +# Host platform doesn't support OTA, so we can't test SHA256 indirectly +# The SHA256 component is tested via unit tests instead +esphome: + on_boot: + - lambda: |- + ESP_LOGI("SHA256", "SHA256 component available on host for library use"); diff --git a/tests/components/sha256/test.rp2040-ard.yaml b/tests/components/sha256/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/sha256/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/sha512/common.yaml b/tests/components/sha512/common.yaml deleted file mode 100644 index 2f254dbfc4..0000000000 --- a/tests/components/sha512/common.yaml +++ /dev/null @@ -1 +0,0 @@ -sha256: diff --git a/tests/components/sha512/test.bk72xx-ard.yaml b/tests/components/sha512/test.bk72xx-ard.yaml deleted file mode 100644 index 25cb37a0b4..0000000000 --- a/tests/components/sha512/test.bk72xx-ard.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - common: !include common.yaml From d1fb3336f00c27884e999987759fd0a06ddb942f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:42:51 -0600 Subject: [PATCH 44/47] reen --- tests/components/sha256/test.host.yaml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/components/sha256/test.host.yaml b/tests/components/sha256/test.host.yaml index 1f50d9ea38..dade44d145 100644 --- a/tests/components/sha256/test.host.yaml +++ b/tests/components/sha256/test.host.yaml @@ -1,6 +1 @@ -# Host platform doesn't support OTA, so we can't test SHA256 indirectly -# The SHA256 component is tested via unit tests instead -esphome: - on_boot: - - lambda: |- - ESP_LOGI("SHA256", "SHA256 component available on host for library use"); +<<: !include common.yaml From ada1b00cad83e84bd78813bd8f1350388dec5aa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:44:09 -0600 Subject: [PATCH 45/47] use evp interface --- esphome/components/sha256/sha256.cpp | 18 ++++++++++++++---- esphome/components/sha256/sha256.h | 4 ++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 6fa17bb7c0..f3c0625a5a 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -69,13 +69,22 @@ void SHA256::calculate() { #elif defined(USE_HOST) -SHA256::~SHA256() = default; +SHA256::~SHA256() { + if (this->ctx_ && this->ctx_->ctx) { + EVP_MD_CTX_free(this->ctx_->ctx); + this->ctx_->ctx = nullptr; + } +} void SHA256::init() { if (!this->ctx_) { this->ctx_ = std::make_unique(); } - SHA256_Init(&this->ctx_->ctx); + if (this->ctx_->ctx) { + EVP_MD_CTX_free(this->ctx_->ctx); + } + this->ctx_->ctx = EVP_MD_CTX_new(); + EVP_DigestInit_ex(this->ctx_->ctx, EVP_sha256(), nullptr); this->ctx_->calculated = false; } @@ -83,7 +92,7 @@ void SHA256::add(const uint8_t *data, size_t len) { if (!this->ctx_) { this->init(); } - SHA256_Update(&this->ctx_->ctx, data, len); + EVP_DigestUpdate(this->ctx_->ctx, data, len); } void SHA256::calculate() { @@ -91,7 +100,8 @@ void SHA256::calculate() { this->init(); } if (!this->ctx_->calculated) { - SHA256_Final(this->ctx_->hash, &this->ctx_->ctx); + unsigned int len = 32; + EVP_DigestFinal_ex(this->ctx_->ctx, this->ctx_->hash, &len); this->ctx_->calculated = true; } } diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 246a7ca891..89b3218166 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -14,7 +14,7 @@ #elif defined(USE_ESP8266) || defined(USE_RP2040) #include #elif defined(USE_HOST) -#include +#include #elif defined(USE_ARDUINO) #include #endif @@ -54,7 +54,7 @@ class SHA256 { }; #elif defined(USE_HOST) struct SHA256Context { - SHA256_CTX ctx; + EVP_MD_CTX *ctx{nullptr}; uint8_t hash[32]; bool calculated{false}; }; From 3aa7da60e6c18515c8d78fa5c3ebc3b9e7938efe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:56:08 -0600 Subject: [PATCH 46/47] fix libretiny --- esphome/components/sha256/sha256.cpp | 31 +--------------------------- esphome/components/sha256/sha256.h | 14 ++++--------- esphome/core/helpers.h | 11 ++++++++++ 3 files changed, 16 insertions(+), 40 deletions(-) diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index f3c0625a5a..62edb5aaa2 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -8,7 +8,7 @@ namespace esphome::sha256 { -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_LIBRETINY) SHA256::~SHA256() { if (this->ctx_) { @@ -106,35 +106,6 @@ void SHA256::calculate() { } } -#elif defined(USE_ARDUINO) - -SHA256::~SHA256() = default; - -void SHA256::init() { - if (!this->ctx_) { - this->ctx_ = std::make_unique(); - } - this->ctx_->sha.reset(); - this->ctx_->calculated = false; -} - -void SHA256::add(const uint8_t *data, size_t len) { - if (!this->ctx_) { - this->init(); - } - this->ctx_->sha.update(data, len); -} - -void SHA256::calculate() { - if (!this->ctx_) { - this->init(); - } - if (!this->ctx_->calculated) { - this->ctx_->sha.finalize(this->ctx_->hash, 32); - this->ctx_->calculated = true; - } -} - #else #error "SHA256 not supported on this platform" #endif diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index 89b3218166..0cea4cdcef 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -9,14 +9,14 @@ #include #include -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_LIBRETINY) #include "mbedtls/sha256.h" #elif defined(USE_ESP8266) || defined(USE_RP2040) #include #elif defined(USE_HOST) #include -#elif defined(USE_ARDUINO) -#include +#else +#error "SHA256 not supported on this platform" #endif namespace esphome::sha256 { @@ -41,7 +41,7 @@ class SHA256 { bool equals_hex(const char *expected); protected: -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_LIBRETINY) struct SHA256Context { mbedtls_sha256_context ctx; uint8_t hash[32]; @@ -58,12 +58,6 @@ class SHA256 { uint8_t hash[32]; bool calculated{false}; }; -#elif defined(USE_ARDUINO) - struct SHA256Context { - ::SHA256 sha; - uint8_t hash[32]; - bool calculated{false}; - }; #else #error "SHA256 not supported on this platform" #endif diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 21aa159b25..a28718de5a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -82,6 +82,16 @@ template constexpr T byteswap(T n) { return m; } template<> constexpr uint8_t byteswap(uint8_t n) { return n; } +#ifdef USE_LIBRETINY +// LibreTiny's Beken framework redefines __builtin_bswap functions as non-constexpr +template<> inline uint16_t byteswap(uint16_t n) { return __builtin_bswap16(n); } +template<> inline uint32_t byteswap(uint32_t n) { return __builtin_bswap32(n); } +template<> inline uint64_t byteswap(uint64_t n) { return __builtin_bswap64(n); } +template<> inline int8_t byteswap(int8_t n) { return n; } +template<> inline int16_t byteswap(int16_t n) { return __builtin_bswap16(n); } +template<> inline int32_t byteswap(int32_t n) { return __builtin_bswap32(n); } +template<> inline int64_t byteswap(int64_t n) { return __builtin_bswap64(n); } +#else template<> constexpr uint16_t byteswap(uint16_t n) { return __builtin_bswap16(n); } template<> constexpr uint32_t byteswap(uint32_t n) { return __builtin_bswap32(n); } template<> constexpr uint64_t byteswap(uint64_t n) { return __builtin_bswap64(n); } @@ -89,6 +99,7 @@ template<> constexpr int8_t byteswap(int8_t n) { return n; } template<> constexpr int16_t byteswap(int16_t n) { return __builtin_bswap16(n); } template<> constexpr int32_t byteswap(int32_t n) { return __builtin_bswap32(n); } template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); } +#endif ///@} From f3ced331a6b1e64d0525a0965cf0097ebb67de37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Sep 2025 10:57:06 -0600 Subject: [PATCH 47/47] no esp32 ard needed --- tests/components/sha256/test.esp32-ard.yaml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 tests/components/sha256/test.esp32-ard.yaml diff --git a/tests/components/sha256/test.esp32-ard.yaml b/tests/components/sha256/test.esp32-ard.yaml deleted file mode 100644 index dade44d145..0000000000 --- a/tests/components/sha256/test.esp32-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common.yaml