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)