From 61cbd07e1d867fc035b34cef26fefedd21b9bf84 Mon Sep 17 00:00:00 2001 From: David Woodhouse Date: Mon, 15 Dec 2025 16:55:03 +0000 Subject: [PATCH 1/4] Add hmac-sha256 support (#12437) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/hmac_sha256/__init__.py | 6 ++ .../components/hmac_sha256/hmac_sha256.cpp | 102 ++++++++++++++++++ esphome/components/hmac_sha256/hmac_sha256.h | 59 ++++++++++ tests/components/hmac_sha256/common.yaml | 34 ++++++ .../hmac_sha256/test.bk72xx-ard.yaml | 1 + .../hmac_sha256/test.esp32-ard.yaml | 1 + .../hmac_sha256/test.esp32-idf.yaml | 1 + .../hmac_sha256/test.esp8266-ard.yaml | 1 + tests/components/hmac_sha256/test.host.yaml | 1 + .../hmac_sha256/test.rp2040-ard.yaml | 1 + .../hmac_sha256/test.rtl87xx-ard.yaml | 1 + 12 files changed, 209 insertions(+) create mode 100644 esphome/components/hmac_sha256/__init__.py create mode 100644 esphome/components/hmac_sha256/hmac_sha256.cpp create mode 100644 esphome/components/hmac_sha256/hmac_sha256.h create mode 100644 tests/components/hmac_sha256/common.yaml create mode 100644 tests/components/hmac_sha256/test.bk72xx-ard.yaml create mode 100644 tests/components/hmac_sha256/test.esp32-ard.yaml create mode 100644 tests/components/hmac_sha256/test.esp32-idf.yaml create mode 100644 tests/components/hmac_sha256/test.esp8266-ard.yaml create mode 100644 tests/components/hmac_sha256/test.host.yaml create mode 100644 tests/components/hmac_sha256/test.rp2040-ard.yaml create mode 100644 tests/components/hmac_sha256/test.rtl87xx-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index af926d2d61..fc27253d23 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -215,6 +215,7 @@ esphome/components/hlk_fm22x/* @OnFreund esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 +esphome/components/hmac_sha256/* @dwmw2 esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/switch/* @Links2004 diff --git a/esphome/components/hmac_sha256/__init__.py b/esphome/components/hmac_sha256/__init__.py new file mode 100644 index 0000000000..158d740dc5 --- /dev/null +++ b/esphome/components/hmac_sha256/__init__.py @@ -0,0 +1,6 @@ +import esphome.config_validation as cv + +AUTO_LOAD = ["sha256"] +CODEOWNERS = ["@dwmw2"] + +CONFIG_SCHEMA = cv.Schema({}) diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp new file mode 100644 index 0000000000..cf5daf63af --- /dev/null +++ b/esphome/components/hmac_sha256/hmac_sha256.cpp @@ -0,0 +1,102 @@ +#include +#include +#include "hmac_sha256.h" +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#include "esphome/core/helpers.h" + +namespace esphome::hmac_sha256 { + +constexpr size_t SHA256_DIGEST_SIZE = 32; + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +HmacSHA256::~HmacSHA256() { mbedtls_md_free(&this->ctx_); } + +void HmacSHA256::init(const uint8_t *key, size_t len) { + mbedtls_md_init(&this->ctx_); + const mbedtls_md_info_t *md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_setup(&this->ctx_, md_info, 1); // 1 = HMAC mode + mbedtls_md_hmac_starts(&this->ctx_, key, len); +} + +void HmacSHA256::add(const uint8_t *data, size_t len) { mbedtls_md_hmac_update(&this->ctx_, data, len); } + +void HmacSHA256::calculate() { mbedtls_md_hmac_finish(&this->ctx_, this->digest_); } + +void HmacSHA256::get_bytes(uint8_t *output) { memcpy(output, this->digest_, SHA256_DIGEST_SIZE); } + +void HmacSHA256::get_hex(char *output) { + for (size_t i = 0; i < SHA256_DIGEST_SIZE; i++) { + sprintf(output + (i * 2), "%02x", this->digest_[i]); + } +} + +bool HmacSHA256::equals_bytes(const uint8_t *expected) { + return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0; +} + +bool HmacSHA256::equals_hex(const char *expected) { + char hex_output[SHA256_DIGEST_SIZE * 2 + 1]; + this->get_hex(hex_output); + hex_output[SHA256_DIGEST_SIZE * 2] = '\0'; + return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0; +} + +#else + +HmacSHA256::~HmacSHA256() = default; + +// HMAC block size for SHA256 (RFC 2104) +constexpr size_t HMAC_BLOCK_SIZE = 64; + +void HmacSHA256::init(const uint8_t *key, size_t len) { + uint8_t ipad[HMAC_BLOCK_SIZE], opad[HMAC_BLOCK_SIZE]; + + memset(ipad, 0, sizeof(ipad)); + if (len > HMAC_BLOCK_SIZE) { + sha256::SHA256 keysha256; + keysha256.init(); + keysha256.add(key, len); + keysha256.calculate(); + keysha256.get_bytes(ipad); + } else { + memcpy(ipad, key, len); + } + memcpy(opad, ipad, sizeof(opad)); + + for (size_t i = 0; i < HMAC_BLOCK_SIZE; i++) { + ipad[i] ^= 0x36; + opad[i] ^= 0x5c; + } + + this->ihash_.init(); + this->ihash_.add(ipad, sizeof(ipad)); + + this->ohash_.init(); + this->ohash_.add(opad, sizeof(opad)); +} + +void HmacSHA256::add(const uint8_t *data, size_t len) { this->ihash_.add(data, len); } + +void HmacSHA256::calculate() { + uint8_t ibytes[32]; + + this->ihash_.calculate(); + this->ihash_.get_bytes(ibytes); + + this->ohash_.add(ibytes, sizeof(ibytes)); + this->ohash_.calculate(); +} + +void HmacSHA256::get_bytes(uint8_t *output) { this->ohash_.get_bytes(output); } + +void HmacSHA256::get_hex(char *output) { this->ohash_.get_hex(output); } + +bool HmacSHA256::equals_bytes(const uint8_t *expected) { return this->ohash_.equals_bytes(expected); } + +bool HmacSHA256::equals_hex(const char *expected) { return this->ohash_.equals_hex(expected); } + +#endif // USE_ESP32 || USE_LIBRETINY + +} // namespace esphome::hmac_sha256 +#endif diff --git a/esphome/components/hmac_sha256/hmac_sha256.h b/esphome/components/hmac_sha256/hmac_sha256.h new file mode 100644 index 0000000000..fa6b64aa94 --- /dev/null +++ b/esphome/components/hmac_sha256/hmac_sha256.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) + +#include + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "mbedtls/md.h" +#else +#include "esphome/components/sha256/sha256.h" +#endif + +namespace esphome::hmac_sha256 { + +class HmacSHA256 { + public: + HmacSHA256() = default; + ~HmacSHA256(); + + /// Initialize a new HMAC-SHA256 digest computation. + void init(const uint8_t *key, size_t len); + void init(const char *key, size_t len) { this->init((const uint8_t *) key, len); } + void init(const std::string &key) { this->init(key.c_str(), key.length()); } + + /// Add bytes of data for the digest. + void add(const uint8_t *data, size_t len); + void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + + /// Compute the digest, based on the provided data. + void calculate(); + + /// Retrieve the HMAC-SHA256 digest as bytes. + /// The output must be able to hold 32 bytes or more. + void get_bytes(uint8_t *output); + + /// Retrieve the HMAC-SHA256 digest as hex characters. + /// The output must be able to hold 64 bytes or more. + void get_hex(char *output); + + /// Compare the digest against a provided byte-encoded digest (32 bytes). + bool equals_bytes(const uint8_t *expected); + + /// Compare the digest against a provided hex-encoded digest (64 bytes). + bool equals_hex(const char *expected); + + protected: +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + static constexpr size_t SHA256_DIGEST_SIZE = 32; + mbedtls_md_context_t ctx_{}; + uint8_t digest_[SHA256_DIGEST_SIZE]{}; +#else + sha256::SHA256 ihash_; + sha256::SHA256 ohash_; +#endif +}; + +} // namespace esphome::hmac_sha256 +#endif diff --git a/tests/components/hmac_sha256/common.yaml b/tests/components/hmac_sha256/common.yaml new file mode 100644 index 0000000000..9bbed295fd --- /dev/null +++ b/tests/components/hmac_sha256/common.yaml @@ -0,0 +1,34 @@ +esphome: + on_boot: + - lambda: |- + // Test HMAC-SHA256 functionality + #ifdef USE_SHA256 + using esphome::hmac_sha256::HmacSHA256; + HmacSHA256 hmac; + + // Test with key "key" and message "The quick brown fox jumps over the lazy dog" + const char* key = "key"; + const char* message = "The quick brown fox jumps over the lazy dog"; + + hmac.init(key, strlen(key)); + hmac.add(message, strlen(message)); + hmac.calculate(); + + char hex_output[65]; + hmac.get_hex(hex_output); + hex_output[64] = '\0'; + + ESP_LOGD("HMAC_SHA256", "HMAC-SHA256('%s', '%s') = %s", key, message, hex_output); + + // Expected: f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 + const char* expected = "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"; + if (strcmp(hex_output, expected) == 0) { + ESP_LOGI("HMAC_SHA256", "Test PASSED"); + } else { + ESP_LOGE("HMAC_SHA256", "Test FAILED. Expected %s", expected); + } + #else + ESP_LOGW("HMAC_SHA256", "HMAC-SHA256 not available on this platform"); + #endif + +hmac_sha256: diff --git a/tests/components/hmac_sha256/test.bk72xx-ard.yaml b/tests/components/hmac_sha256/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.esp32-ard.yaml b/tests/components/hmac_sha256/test.esp32-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.esp32-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.esp32-idf.yaml b/tests/components/hmac_sha256/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.esp8266-ard.yaml b/tests/components/hmac_sha256/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.host.yaml b/tests/components/hmac_sha256/test.host.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.host.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.rp2040-ard.yaml b/tests/components/hmac_sha256/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.rtl87xx-ard.yaml b/tests/components/hmac_sha256/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.rtl87xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 2e899dd010adbcb3dc880e5d009a6a1568f566cd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:07:02 -0500 Subject: [PATCH 2/4] [esp32] Support all IDF component version operators in shorthand syntax (#12499) Co-authored-by: Claude --- esphome/components/esp32/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 3dc5e4bbaa..0142fd4841 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -4,6 +4,7 @@ import itertools import logging import os from pathlib import Path +import re from esphome import yaml_util import esphome.codegen as cg @@ -616,10 +617,13 @@ def require_vfs_dir() -> None: def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" - if "^" not in value: - raise cv.Invalid(f"Invalid IDF component shorthand '{value}'") - name, ref = value.split("^", 1) - return {CONF_NAME: name, CONF_REF: ref} + # Match operator followed by version-like string (digit or *) + if match := re.search(r"(~=|>=|<=|==|!=|>|<|\^|~)(\d|\*)", value): + return {CONF_NAME: value[: match.start()], CONF_REF: value[match.start() :]} + raise cv.Invalid( + f"Invalid IDF component shorthand '{value}'. " + f"Expected format: 'owner/componentversion' where is one of: ^, ~, ~=, ==, !=, >=, >, <=, <" + ) def _validate_idf_component(config: ConfigType) -> ConfigType: From 260ffba2a59f3c9db2ebca87ffdb4342902ceb2c Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 15 Dec 2025 18:54:12 +0100 Subject: [PATCH 3/4] [http_request] Fix infinite loop when server doesn't send Content-Length header (#12480) Co-authored-by: Claude Sonnet 4.5 --- .../components/http_request/http_request.h | 3 +++ .../http_request/ota/ota_http_request.cpp | 20 ++++++++++++++----- .../update/http_request_update.cpp | 5 +++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 8a82a44d7d..8adf13b954 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -255,6 +255,9 @@ template class HttpRequestSendAction : public Action { size_t read_index = 0; while (container->get_bytes_read() < max_length) { int read = container->read(buf + read_index, std::min(max_length - read_index, 512)); + if (read <= 0) { + break; + } App.feed_wdt(); yield(); read_index += read; diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 4552fcc9df..b257518e06 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -132,11 +132,18 @@ uint8_t OtaHttpRequestComponent::do_ota_() { App.feed_wdt(); yield(); - if (bufsize < 0) { - ESP_LOGE(TAG, "Stream closed"); - this->cleanup_(std::move(backend), container); - return OTA_CONNECTION_ERROR; - } else if (bufsize > 0 && bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) { + // Exit loop if no data available (stream closed or end of data) + if (bufsize <= 0) { + if (bufsize < 0) { + ESP_LOGE(TAG, "Stream closed with error"); + this->cleanup_(std::move(backend), container); + return OTA_CONNECTION_ERROR; + } + // bufsize == 0: no more data available, exit loop + break; + } + + if (bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) { // add read bytes to MD5 md5_receive.add(buf, bufsize); @@ -247,6 +254,9 @@ bool OtaHttpRequestComponent::http_get_md5_() { int read_len = 0; while (container->get_bytes_read() < MD5_SIZE) { read_len = container->read((uint8_t *) this->md5_expected_.data(), MD5_SIZE); + if (read_len <= 0) { + break; + } App.feed_wdt(); yield(); } diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 26af754e69..22cad625d1 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -76,6 +76,11 @@ void HttpRequestUpdate::update_task(void *params) { yield(); + if (read_bytes <= 0) { + // Network error or connection closed - break to avoid infinite loop + break; + } + read_index += read_bytes; } From f6f1961e0eb8c634e52789161d33150c997d1ea4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 15 Dec 2025 12:24:34 -0600 Subject: [PATCH 4/4] [text_sensor] Avoid string copies in callbacks by passing const ref --- esphome/components/text_sensor/text_sensor.cpp | 4 ++-- esphome/components/text_sensor/text_sensor.h | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 35921ec8fc..c147e59596 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -73,11 +73,11 @@ void TextSensor::clear_filters() { this->filter_list_ = nullptr; } -void TextSensor::add_on_state_callback(std::function callback) { +void TextSensor::add_on_state_callback(std::function callback) { this->callbacks_.add_second(std::move(callback)); } -void TextSensor::add_on_raw_state_callback(std::function callback) { +void TextSensor::add_on_raw_state_callback(std::function callback) { this->callbacks_.add_first(std::move(callback), &this->raw_count_); } diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 6410cbd961..177c9badaf 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -55,9 +55,9 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { /// Clear the entire filter chain. void clear_filters(); - void add_on_state_callback(std::function callback); + void add_on_state_callback(std::function callback); /// Add a callback that will be called every time the sensor sends a raw value. - void add_on_raw_state_callback(std::function callback); + void add_on_raw_state_callback(std::function callback); // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -65,7 +65,7 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { void internal_send_state_to_frontend(const std::string &state); protected: - PartitionedCallbackManager callbacks_; + PartitionedCallbackManager callbacks_; Filter *filter_list_{nullptr}; ///< Store all active filters.