diff --git a/CODEOWNERS b/CODEOWNERS index 9ddbca5c71..3047072ea2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -381,6 +381,7 @@ esphome/components/nextion/switch/* @senexcrenshaw esphome/components/nextion/text_sensor/* @senexcrenshaw esphome/components/nfc/* @jesserockz @kbx81 esphome/components/noblex/* @AGalfra +esphome/components/noise/* @esphome/core esphome/components/npi19/* @bakerkj esphome/components/nrf52/* @tomaszduda23 esphome/components/number/* @esphome/core diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 0dc4b905bf..53ad0fe5d7 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,4 +1,3 @@ -import base64 import logging from typing import Any @@ -6,6 +5,15 @@ from esphome import automation from esphome.automation import Condition import esphome.codegen as cg from esphome.components.logger import request_log_listener + +# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external +# components and downstream consumers that import them from api +from esphome.components.noise import ( # noqa: F401 + ENCRYPTION_SCHEMA, + decode_encryption_key, + encryption_schema, + validate_encryption_key, +) from esphome.config_helpers import get_logger_level import esphome.config_validation as cv from esphome.const import ( @@ -38,6 +46,10 @@ from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_pr from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigFragmentType, ConfigType +# Compat alias: downstream consumers (e.g. device-builder) referenced the +# schema by its old private name before it moved to the noise component +_encryption_schema = encryption_schema + _LOGGER = logging.getLogger(__name__) DOMAIN = "api" @@ -46,9 +58,15 @@ CODEOWNERS = ["@esphome/core"] def AUTO_LOAD(config: ConfigType) -> list[str]: - """Conditionally auto-load json only when capture_response is used.""" + """Conditionally auto-load noise (encryption) and json (capture_response).""" base = ["socket"] + # A falsy config is a tooling probe for the maximal set (None from + # dependency resolution, {} from the components-graph platform probe); + # a validated config always carries defaults, never empty + if not config or CONF_ENCRYPTION in config: + base = base + ["noise"] + # Check if any homeassistant.action/homeassistant.service has capture_response: true # This flag is set during config validation in _validate_response_config if not config or CORE.data.get(DOMAIN, {}).get(CONF_CAPTURE_RESPONSE, False): @@ -130,20 +148,6 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType: return config -def validate_encryption_key(value: Any) -> str: - value = cv.string_strict(value) - try: - decoded = base64.b64decode(value, validate=True) - except ValueError as err: - raise cv.Invalid("Invalid key format, please check it's using base64") from err - - if len(decoded) != 32: - raise cv.Invalid("Encryption key must be base64 and 32 bytes long") - - # Return original data for roundtrip conversion - return value - - CONF_SUPPORTS_RESPONSE = "supports_response" # Enum values in api::enums namespace @@ -250,18 +254,6 @@ ACTIONS_SCHEMA = automation.validate_automation( ), ) -ENCRYPTION_SCHEMA = cv.Schema( - { - cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key), - } -) - - -def _encryption_schema(config: ConfigType | None) -> ConfigType: - if config is None: - config = {} - return ENCRYPTION_SCHEMA(config) - def _consume_api_sockets(config: ConfigType) -> ConfigType: """Register socket needs for API component.""" @@ -297,7 +289,7 @@ CONFIG_SCHEMA = cv.All( CONF_SERVICES, group_of_exclusion=CONF_ACTIONS ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, - cv.Optional(CONF_ENCRYPTION): _encryption_schema, + cv.Optional(CONF_ENCRYPTION): encryption_schema, cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), @@ -484,7 +476,7 @@ async def to_code(config: ConfigType) -> None: if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None: if key := encryption_config.get(CONF_KEY): - decoded = base64.b64decode(key) + decoded = decode_encryption_key(key) cg.add(var.set_noise_psk(list(decoded))) cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: @@ -498,10 +490,6 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.21") - # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops - cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") - cg.add_build_flag("-DHAVE_INLINE_ASM=1") else: cg.add_define("USE_API_PLAINTEXT") diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c1dded1271..91d13eed65 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2130,7 +2130,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } #endif - psk_t psk{}; + noise::psk_t psk{}; if (msg.key_len == 0) { if (this->parent_->clear_noise_psk(true)) { resp.success = true; @@ -2139,7 +2139,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } } else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); - } else if (APINoiseContext::is_all_zeros(psk)) { + } else if (noise::NoiseContext::is_all_zeros(psk)) { // Accepting the reserved provisioning PSK would report success without // enabling encryption (or silently clear an existing key) ESP_LOGW(TAG, "Rejecting all-zero encryption key"); diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 09e3ca2b9e..d7554e62c5 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -2,9 +2,9 @@ #ifdef USE_API #ifdef USE_API_NOISE #include "api_connection.h" // For ClientInfo struct +#include "esphome/components/noise/noise.h" #include "esphome/core/application.h" #include "esphome/core/entity_base.h" -#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "proto.h" @@ -17,6 +17,14 @@ namespace esphome::api { +using noise::noise_err_to_logstr; + +// api_frame_helper.h keeps its own MAX_HANDSHAKE_SIZE because that header is +// also compiled in plaintext-only builds without the noise component; keep +// the two definitions from drifting apart. +static_assert(MAX_HANDSHAKE_SIZE == noise::MAX_HANDSHAKE_SIZE, + "api and noise component handshake size limits must match"); + static const char *const TAG = "api.noise"; #ifdef USE_ESP8266 static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit"; @@ -51,45 +59,6 @@ static constexpr size_t API_MAX_LOG_BYTES = 168; #define LOG_PACKET_RECEIVED(buffer) ((void) 0) #endif -/// Convert a noise error code to a readable error -const LogString *noise_err_to_logstr(int err) { - if (err == NOISE_ERROR_NO_MEMORY) - return LOG_STR("NO_MEMORY"); - if (err == NOISE_ERROR_UNKNOWN_ID) - return LOG_STR("UNKNOWN_ID"); - if (err == NOISE_ERROR_UNKNOWN_NAME) - return LOG_STR("UNKNOWN_NAME"); - if (err == NOISE_ERROR_MAC_FAILURE) - return LOG_STR("MAC_FAILURE"); - if (err == NOISE_ERROR_NOT_APPLICABLE) - return LOG_STR("NOT_APPLICABLE"); - if (err == NOISE_ERROR_SYSTEM) - return LOG_STR("SYSTEM"); - if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED) - return LOG_STR("REMOTE_KEY_REQUIRED"); - if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED) - return LOG_STR("LOCAL_KEY_REQUIRED"); - if (err == NOISE_ERROR_PSK_REQUIRED) - return LOG_STR("PSK_REQUIRED"); - if (err == NOISE_ERROR_INVALID_LENGTH) - return LOG_STR("INVALID_LENGTH"); - if (err == NOISE_ERROR_INVALID_PARAM) - return LOG_STR("INVALID_PARAM"); - if (err == NOISE_ERROR_INVALID_STATE) - return LOG_STR("INVALID_STATE"); - if (err == NOISE_ERROR_INVALID_NONCE) - return LOG_STR("INVALID_NONCE"); - if (err == NOISE_ERROR_INVALID_PRIVATE_KEY) - return LOG_STR("INVALID_PRIVATE_KEY"); - if (err == NOISE_ERROR_INVALID_PUBLIC_KEY) - return LOG_STR("INVALID_PUBLIC_KEY"); - if (err == NOISE_ERROR_INVALID_FORMAT) - return LOG_STR("INVALID_FORMAT"); - if (err == NOISE_ERROR_INVALID_SIGNATURE) - return LOG_STR("INVALID_SIGNATURE"); - return LOG_STR("UNKNOWN"); -} - /// Initialize the frame helper, returns OK if successful. APIError APINoiseFrameHelper::init() { APIError err = init_common_(); @@ -194,9 +163,9 @@ APIError APINoiseFrameHelper::loop() { */ APIError APINoiseFrameHelper::try_read_frame_() { // read header - if (rx_header_buf_len_ < 3) { + if (rx_header_buf_len_ < noise::FRAME_HEADER_SIZE) { // no header information yet - uint8_t to_read = 3 - rx_header_buf_len_; + uint8_t to_read = static_cast(noise::FRAME_HEADER_SIZE) - rx_header_buf_len_; ssize_t received = this->socket_->read(&rx_header_buf_[rx_header_buf_len_], to_read); APIError err = handle_socket_read_result_(received); if (err != APIError::OK) { @@ -208,7 +177,7 @@ APIError APINoiseFrameHelper::try_read_frame_() { return APIError::WOULD_BLOCK; } - if (rx_header_buf_[0] != 0x01) { + if (rx_header_buf_[0] != noise::FRAME_INDICATOR) { state_ = State::FAILED; HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); return APIError::BAD_INDICATOR; @@ -348,15 +317,15 @@ APIError APINoiseFrameHelper::state_action_server_hello_() { return APIError::OK; } APIError APINoiseFrameHelper::state_action_handshake_() { - int action = noise_handshakestate_get_action(this->handshake_); - if (action == NOISE_ACTION_READ_MESSAGE) { + noise::NoiseResponderHandshake::Action action = this->handshake_.action(); + if (action == noise::NoiseResponderHandshake::Action::ACTION_READ) { return this->state_action_handshake_read_(); - } else if (action == NOISE_ACTION_WRITE_MESSAGE) { + } else if (action == noise::NoiseResponderHandshake::Action::ACTION_WRITE) { return this->state_action_handshake_write_(); } // bad state for action this->state_ = State::FAILED; - HELPER_LOG("Bad action for handshake: %d", action); + HELPER_LOG("Bad action for handshake: %d", (int) action); return APIError::HANDSHAKESTATE_BAD_STATE; } APIError APINoiseFrameHelper::state_action_handshake_read_() { @@ -368,20 +337,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() { if (this->rx_buf_.empty()) { this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message")); return APIError::BAD_HANDSHAKE_ERROR_BYTE; - } else if (this->rx_buf_[0] != 0x00) { + } else if (this->rx_buf_[0] != noise::HANDSHAKE_STATUS_OK) { HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]); this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte")); return APIError::BAD_HANDSHAKE_ERROR_BYTE; } - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1); - int err = noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr); + int err = this->handshake_.read_message(this->rx_buf_.data() + 1, this->rx_buf_.size() - 1); if (err != 0) { // Special handling for MAC failure - this->send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure") - : LOG_STR("Handshake error")); + this->send_explicit_handshake_reject_(noise::reject_reason_for(err)); return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"), APIError::HANDSHAKESTATE_READ_FAILED); } @@ -390,18 +355,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() { } APIError APINoiseFrameHelper::state_action_handshake_write_() { uint8_t buffer[65]; - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1); + size_t msg_len = 0; - int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr); + int err = this->handshake_.write_message(buffer + 1, sizeof(buffer) - 1, msg_len); APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"), APIError::HANDSHAKESTATE_WRITE_FAILED); if (aerr != APIError::OK) return aerr; - buffer[0] = 0x00; // success + buffer[0] = noise::HANDSHAKE_STATUS_OK; - aerr = this->write_frame_(buffer, mbuf.size + 1); + aerr = this->write_frame_(buffer, msg_len + 1); if (aerr != APIError::OK) return aerr; return this->check_handshake_finished_(); @@ -409,33 +372,22 @@ APIError APINoiseFrameHelper::state_action_handshake_write_() { void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) { // Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes uint8_t data[32]; - data[0] = 0x01; // failure - -#ifdef USE_STORE_LOG_STR_IN_FLASH - // On ESP8266 with flash strings, we need to use PROGMEM-aware functions - size_t reason_len = strlen_P(reinterpret_cast(reason)); - reason_len = std::min(reason_len, sizeof(data) - 1); - if (reason_len > 0) { - memcpy_P(data + 1, reinterpret_cast(reason), reason_len); - } -#else - // Normal memory access - const char *reason_str = LOG_STR_ARG(reason); - size_t reason_len = strlen(reason_str); - reason_len = std::min(reason_len, sizeof(data) - 1); - if (reason_len > 0) { - // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string - std::memcpy(data + 1, reason_str, reason_len); - } -#endif - - size_t data_size = reason_len + 1; + static_assert(sizeof(data) >= noise::MAC_FAILURE_PAYLOAD_SIZE, + "reject buffer must fit the MAC failure wire contract"); + size_t data_size = noise::format_reject_payload(data, sizeof(data), reason); // temporarily remove failed state auto orig_state = state_; state_ = State::EXPLICIT_REJECT; - write_frame_(data, data_size); - state_ = orig_state; + APIError aerr = write_frame_(data, data_size); + if (aerr != APIError::OK) { + // Best effort; the reject reason is a diagnosis aid, not a protocol step + ESP_LOGW(TAG, "Sending handshake reject failed: %d", (int) aerr); + } + if (state_ == State::EXPLICIT_REJECT) { + // write_frame_ may have moved the state to FAILED; keep that decision + state_ = orig_state; + } } APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { APIError aerr = this->check_data_state_(); @@ -492,12 +444,10 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { // Returns APIError::OK on success. APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type, uint16_t &encrypted_len_out) { - // Write noise header - buf_start[0] = 0x01; // indicator - // buf_start[1], buf_start[2] to be set after encryption + // The noise frame header is written after encryption, when the size is known // Write message header (to be encrypted) - constexpr uint8_t msg_offset = 3; + constexpr uint8_t msg_offset = noise::FRAME_HEADER_SIZE; buf_start[msg_offset] = static_cast(message_type >> 8); // type high byte buf_start[msg_offset + 1] = static_cast(message_type); // type low byte buf_start[msg_offset + 2] = static_cast(payload_size >> 8); // data_len high byte @@ -515,11 +465,10 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_ if (aerr != APIError::OK) return aerr; - // Fill in the encrypted size - buf_start[1] = static_cast(mbuf.size >> 8); - buf_start[2] = static_cast(mbuf.size); + // Fill in the frame header now that the encrypted size is known + noise::write_frame_header(buf_start, static_cast(mbuf.size)); - encrypted_len_out = static_cast(3 + mbuf.size); // indicator + size + encrypted data + encrypted_len_out = static_cast(noise::FRAME_HEADER_SIZE + mbuf.size); return APIError::OK; } @@ -568,21 +517,19 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s } APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { - uint8_t header[3]; - header[0] = 0x01; // indicator - header[1] = (uint8_t) (len >> 8); - header[2] = (uint8_t) len; + uint8_t header[noise::FRAME_HEADER_SIZE]; + noise::write_frame_header(header, len); if (len == 0) { - return this->write_raw_buf_(header, 3); + return this->write_raw_buf_(header, noise::FRAME_HEADER_SIZE); } struct iovec iov[2]; iov[0].iov_base = header; - iov[0].iov_len = 3; + iov[0].iov_len = noise::FRAME_HEADER_SIZE; iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return this->write_raw_iov_(iov, 2, 3 + len); + return this->write_raw_iov_(iov, 2, noise::FRAME_HEADER_SIZE + len); } /** Initiate the data structures for the handshake. @@ -590,45 +537,12 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { * @return 0 on success, -1 on error (check errno) */ APIError APINoiseFrameHelper::init_handshake_() { - int err; - // Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack: - // noise_handshakestate_new_by_id copies it, so a member would waste - // 104 bytes per connection, and a static const would sit in RAM on - // ESP8266 (.rodata is DRAM there). - const NoiseProtocolId nid = { - .prefix_id = NOISE_PREFIX_STANDARD, - .pattern_id = NOISE_PATTERN_NN, - .modifier_ids = {NOISE_MODIFIER_PSK0}, - .dh_id = NOISE_DH_CURVE25519, - .cipher_id = NOISE_CIPHER_CHACHAPOLY, - .hash_id = NOISE_HASH_SHA256, - .hybrid_id = NOISE_DH_NONE, - }; - - err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER); - APIError aerr = - handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED); + int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size()); + APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; - - const auto &psk = this->ctx_.get_psk(); - err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size()); - aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_pre_shared_key"), - APIError::HANDSHAKESTATE_SETUP_FAILED); - if (aerr != APIError::OK) - return aerr; - - err = noise_handshakestate_set_prologue(handshake_, prologue_.data(), prologue_.size()); - aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_prologue"), APIError::HANDSHAKESTATE_SETUP_FAILED); - if (aerr != APIError::OK) - return aerr; - // set_prologue copies it into handshakestate, so we can get rid of it now + // init copies the prologue into the handshakestate, so we can get rid of it now prologue_.release(); - - err = noise_handshakestate_start(handshake_); - aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED); - if (aerr != APIError::OK) - return aerr; return APIError::OK; } @@ -637,15 +551,17 @@ APIError APINoiseFrameHelper::check_handshake_finished_() { assert(state_ == State::HANDSHAKE); #endif - int action = noise_handshakestate_get_action(handshake_); - if (action == NOISE_ACTION_READ_MESSAGE || action == NOISE_ACTION_WRITE_MESSAGE) + noise::NoiseResponderHandshake::Action action = this->handshake_.action(); + if (action == noise::NoiseResponderHandshake::Action::ACTION_READ || + action == noise::NoiseResponderHandshake::Action::ACTION_WRITE) return APIError::OK; - if (action != NOISE_ACTION_SPLIT) { + if (action != noise::NoiseResponderHandshake::Action::ACTION_SPLIT) { state_ = State::FAILED; - HELPER_LOG("Bad action for handshake: %d", action); + HELPER_LOG("Bad action for handshake: %d", (int) action); return APIError::HANDSHAKESTATE_BAD_STATE; } - int err = noise_handshakestate_split(handshake_, &send_cipher_, &recv_cipher_); + // split() also frees the handshake state + int err = this->handshake_.split(send_cipher_, recv_cipher_); APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_split"), APIError::HANDSHAKESTATE_SPLIT_FAILED); if (aerr != APIError::OK) @@ -654,17 +570,11 @@ APIError APINoiseFrameHelper::check_handshake_finished_() { this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_); HELPER_LOG("Handshake complete!"); - noise_handshakestate_free(handshake_); - handshake_ = nullptr; state_ = State::DATA; return APIError::OK; } APINoiseFrameHelper::~APINoiseFrameHelper() { - if (handshake_ != nullptr) { - noise_handshakestate_free(handshake_); - handshake_ = nullptr; - } if (send_cipher_ != nullptr) { noise_cipherstate_free(send_cipher_); send_cipher_ = nullptr; @@ -675,16 +585,6 @@ APINoiseFrameHelper::~APINoiseFrameHelper() { } } -extern "C" { -// declare how noise generates random bytes (here with a good HWRNG based on the RF system) -void noise_rand_bytes(void *output, size_t len) { - if (!esphome::random_bytes(reinterpret_cast(output), len)) { - ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting"); - arch_restart(); - } -} -} - } // namespace esphome::api #endif // USE_API_NOISE #endif // USE_API diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 46bd366672..05060c77de 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -3,7 +3,7 @@ #ifdef USE_API #ifdef USE_API_NOISE #include "noise/protocol.h" -#include "api_noise_context.h" +#include "esphome/components/noise/noise_handshake.h" namespace esphome::api { @@ -14,9 +14,9 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Pos 1-2: encrypted payload size (16-bit big-endian) // Pos 3-6: encrypted type (16-bit) + data_len (16-bit) // Pos 7+: actual payload data - static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len + static constexpr uint8_t HEADER_PADDING = noise::FRAME_HEADER_SIZE + 2 + 2; // frame header + type + data_len - APINoiseFrameHelper(std::unique_ptr socket, APINoiseContext &ctx) + APINoiseFrameHelper(std::unique_ptr socket, noise::NoiseContext &ctx) : APIFrameHelper(std::move(socket)), ctx_(ctx) { frame_header_padding_ = HEADER_PADDING; } @@ -52,13 +52,13 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError handle_handshake_frame_error_(APIError aerr); APIError handle_noise_error_(int err, const LogString *func_name, APIError api_err); - // Pointers first (4 bytes each) - NoiseHandshakeState *handshake_{nullptr}; + // Pointers first (4 bytes each; the handshake wrapper holds one pointer) + noise::NoiseResponderHandshake handshake_; NoiseCipherState *send_cipher_{nullptr}; NoiseCipherState *recv_cipher_{nullptr}; // Reference to noise context (4 bytes on 32-bit) - APINoiseContext &ctx_; + noise::NoiseContext &ctx_; // Buffer for noise handshake prologue (released after handshake) APIBuffer prologue_; @@ -67,7 +67,7 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Fixed-size header buffer for noise protocol: // 1 byte for indicator + 2 bytes for message size (16-bit value, not varint) // Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase - uint8_t rx_header_buf_[3]; + uint8_t rx_header_buf_[noise::FRAME_HEADER_SIZE]; uint8_t rx_header_buf_len_ = 0; // 4 bytes total, no padding }; diff --git a/esphome/components/api/api_noise_context.h b/esphome/components/api/api_noise_context.h deleted file mode 100644 index 44484ffa2c..0000000000 --- a/esphome/components/api/api_noise_context.h +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once -#include -#include -#include "esphome/core/defines.h" - -namespace esphome::api { - -#ifdef USE_API_NOISE -using psk_t = std::array; - -class APINoiseContext { - public: - // The all-zeros PSK is reserved: it marks the device as unprovisioned and - // doubles as the well-known provisioning PSK that unprovisioned devices - // accept for Noise handshakes (passive-sniffing protection only, no - // authentication). It is never a valid real key. - static bool is_all_zeros(const psk_t &psk) { - uint8_t acc = 0; - for (uint8_t b : psk) { - acc |= b; - } - return acc == 0; - } - void set_psk(psk_t psk) { - this->psk_ = psk; - this->has_psk_ = !is_all_zeros(psk); - } - const psk_t &get_psk() const { return this->psk_; } - bool has_psk() const { return this->has_psk_; } - - protected: - psk_t psk_{}; - bool has_psk_{false}; -}; -#endif // USE_API_NOISE - -} // namespace esphome::api diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 2d5f9e4155..751f2e4c3b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -588,7 +588,7 @@ bool APIServer::load_and_apply_noise_psk_() { return true; } -bool APIServer::save_noise_psk(psk_t psk, bool make_active) { +bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) { #ifdef USE_API_NOISE_PSK_FROM_YAML // When PSK is set from YAML, this function should never be called // but if it is, reject the change diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index a58e42534b..072a583901 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -5,7 +5,10 @@ #include "api_buffer.h" // Must precede clients_ so APIConnection is complete for default_delete (libc++). #include "api_connection.h" -#include "api_noise_context.h" +#ifdef USE_API_NOISE +// Only present in the build when the noise component is loaded +#include "esphome/components/noise/noise.h" +#endif #include "api_pb2.h" #include "api_pb2_service.h" #include "esphome/components/socket/socket.h" @@ -37,7 +40,7 @@ class UserServiceDescriptor; #ifdef USE_API_NOISE struct SavedNoisePsk { - psk_t psk; + noise::psk_t psk; } PACKED; // NOLINT #endif @@ -73,10 +76,10 @@ class APIServer final : public Component, APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; } #ifdef USE_API_NOISE - bool save_noise_psk(psk_t psk, bool make_active = true); + bool save_noise_psk(noise::psk_t psk, bool make_active = true); bool clear_noise_psk(bool make_active = true); - void set_noise_psk(psk_t psk) { this->noise_ctx_.set_psk(psk); } - APINoiseContext &get_noise_ctx() { return this->noise_ctx_; } + void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); } + noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; } #endif // USE_API_NOISE void handle_disconnect(APIConnection *conn); @@ -354,7 +357,7 @@ class APIServer final : public Component, #endif #ifdef USE_API_NOISE - APINoiseContext noise_ctx_; + noise::NoiseContext noise_ctx_; ESPPreferenceObject noise_pref_; #endif // USE_API_NOISE }; diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py new file mode 100644 index 0000000000..e5fcc94332 --- /dev/null +++ b/esphome/components/noise/__init__.py @@ -0,0 +1,69 @@ +import base64 +import binascii +from typing import Any + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_KEY +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] + +noise_ns = cg.esphome_ns.namespace("noise") + +CONFIG_SCHEMA = cv.Schema({}) + + +def validate_encryption_key(value: Any) -> str: + value = cv.string_strict(value) + try: + decoded = base64.b64decode(value, validate=True) + except ValueError as err: + raise cv.Invalid("Invalid key format, please check it's using base64") from err + + if len(decoded) != 32: + raise cv.Invalid("Encryption key must be base64 and 32 bytes long") + + # Return original data for roundtrip conversion + return value + + +def decode_encryption_key(value: str) -> bytes: + """Decode a base64 encryption key to its 32 raw bytes. + + a2b_base64 matches the decode the clients use (aioesphomeapi + decode_noise_psk), so both ends derive the same bytes. The length is + re-checked so a caller cannot turn an unvalidated short decode into a + zero-padded PSK. + """ + try: + decoded = binascii.a2b_base64(value) + except ValueError as err: + raise cv.Invalid("Invalid key format, please check it's using base64") from err + if len(decoded) != 32: + raise cv.Invalid("Encryption key must be base64 and 32 bytes long") + return decoded + + +ENCRYPTION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key), + } +) + + +def encryption_schema(config: ConfigType | None) -> ConfigType: + # A bare `encryption:` block is valid; a missing key means the consumer + # falls back to its keyless behavior (api provisioning, ota inheriting + # the api key). + if config is None: + config = {} + return ENCRYPTION_SCHEMA(config) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_NOISE") + cg.add_library("esphome/noise-c", "0.1.21") + # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops + cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") + cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp new file mode 100644 index 0000000000..95fab322db --- /dev/null +++ b/esphome/components/noise/noise.cpp @@ -0,0 +1,88 @@ +#include "noise.h" +#ifdef USE_NOISE +#include "esphome/core/log.h" + +#include +#include + +#include + +#ifdef USE_ESP8266 +#include +#endif + +namespace esphome::noise { + +static const char *const TAG = "noise"; + +const LogString *noise_err_to_logstr(int err) { + if (err == NOISE_ERROR_NO_MEMORY) + return LOG_STR("NO_MEMORY"); + if (err == NOISE_ERROR_UNKNOWN_ID) + return LOG_STR("UNKNOWN_ID"); + if (err == NOISE_ERROR_UNKNOWN_NAME) + return LOG_STR("UNKNOWN_NAME"); + if (err == NOISE_ERROR_MAC_FAILURE) + return LOG_STR("MAC_FAILURE"); + if (err == NOISE_ERROR_NOT_APPLICABLE) + return LOG_STR("NOT_APPLICABLE"); + if (err == NOISE_ERROR_SYSTEM) + return LOG_STR("SYSTEM"); + if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED) + return LOG_STR("REMOTE_KEY_REQUIRED"); + if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED) + return LOG_STR("LOCAL_KEY_REQUIRED"); + if (err == NOISE_ERROR_PSK_REQUIRED) + return LOG_STR("PSK_REQUIRED"); + if (err == NOISE_ERROR_INVALID_LENGTH) + return LOG_STR("INVALID_LENGTH"); + if (err == NOISE_ERROR_INVALID_PARAM) + return LOG_STR("INVALID_PARAM"); + if (err == NOISE_ERROR_INVALID_STATE) + return LOG_STR("INVALID_STATE"); + if (err == NOISE_ERROR_INVALID_NONCE) + return LOG_STR("INVALID_NONCE"); + if (err == NOISE_ERROR_INVALID_PRIVATE_KEY) + return LOG_STR("INVALID_PRIVATE_KEY"); + if (err == NOISE_ERROR_INVALID_PUBLIC_KEY) + return LOG_STR("INVALID_PUBLIC_KEY"); + if (err == NOISE_ERROR_INVALID_FORMAT) + return LOG_STR("INVALID_FORMAT"); + if (err == NOISE_ERROR_INVALID_SIGNATURE) + return LOG_STR("INVALID_SIGNATURE"); + return LOG_STR("UNKNOWN"); +} + +const LogString *reject_reason_for(int err) { + return err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure") : LOG_STR("Handshake error"); +} + +size_t format_reject_payload(uint8_t *buf, size_t capacity, const LogString *reason) { + if (capacity == 0) { + // A caller bug; the MAC_FAILURE_PAYLOAD_SIZE static_asserts at the call + // sites make this unreachable, kept as cheap memory safety + ESP_LOGVV(TAG, "Reject buffer has no capacity"); + return 0; + } + buf[0] = HANDSHAKE_STATUS_REJECT; +#ifdef USE_STORE_LOG_STR_IN_FLASH + // On ESP8266 with flash strings, we need to use PROGMEM-aware functions + size_t reason_len = strlen_P(reinterpret_cast(reason)); + reason_len = std::min(reason_len, capacity - 1); + if (reason_len > 0) { + memcpy_P(buf + 1, reinterpret_cast(reason), reason_len); + } +#else + const char *reason_str = LOG_STR_ARG(reason); + size_t reason_len = strlen(reason_str); + reason_len = std::min(reason_len, capacity - 1); + if (reason_len > 0) { + // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string + std::memcpy(buf + 1, reason_str, reason_len); + } +#endif + return reason_len + 1; +} + +} // namespace esphome::noise +#endif // USE_NOISE diff --git a/esphome/components/noise/noise.h b/esphome/components/noise/noise.h new file mode 100644 index 0000000000..f9da8d35b8 --- /dev/null +++ b/esphome/components/noise/noise.h @@ -0,0 +1,74 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_NOISE +#include +#include +#include +#include "esphome/core/log.h" + +namespace esphome::noise { + +using psk_t = std::array; + +class NoiseContext { + public: + // The all-zeros PSK is reserved: it marks the device as unprovisioned and + // doubles as the well-known provisioning PSK that unprovisioned devices + // accept for Noise handshakes (passive-sniffing protection only, no + // authentication). It is never a valid real key. + static bool is_all_zeros(const psk_t &psk) { + uint8_t acc = 0; + for (uint8_t b : psk) { + acc |= b; + } + return acc == 0; + } + void set_psk(psk_t psk) { + this->psk_ = psk; + this->has_psk_ = !is_all_zeros(psk); + } + const psk_t &get_psk() const { return this->psk_; } + bool has_psk() const { return this->has_psk_; } + + protected: + psk_t psk_{}; + bool has_psk_{false}; +}; + +/// Convert a noise error code to a readable error +const LogString *noise_err_to_logstr(int err); + +// Shared wire format for the noise transports (api and ota): every frame is +// FRAME_INDICATOR, a 16-bit big-endian payload length, then the payload. +// Handshake payloads start with a status byte; transport payloads end with +// the ChaCha20-Poly1305 MAC. +static constexpr uint8_t FRAME_INDICATOR = 0x01; +static constexpr size_t FRAME_HEADER_SIZE = 3; +static constexpr size_t MAC_SIZE = 16; +static constexpr size_t MAX_HANDSHAKE_SIZE = 128; +static constexpr uint8_t HANDSHAKE_STATUS_OK = 0x00; +static constexpr uint8_t HANDSHAKE_STATUS_REJECT = 0x01; + +inline void write_frame_header(uint8_t *buf, uint16_t payload_len) { + buf[0] = FRAME_INDICATOR; + buf[1] = (uint8_t) (payload_len >> 8); + buf[2] = (uint8_t) payload_len; +} + +/// Fill buf with a handshake reject payload (status byte plus the reason +/// text, PROGMEM aware); returns the payload length. buf needs capacity for +/// the status byte plus the truncated reason. +size_t format_reject_payload(uint8_t *buf, size_t capacity, const LogString *reason); + +/// Reject reason for a failed handshake read. The MAC failure string is a +/// wire contract: clients match it to report a wrong key. +const LogString *reject_reason_for(int err); + +/// Payload size of the MAC failure reject, the one reason string that is a +/// wire contract (sizeof's NUL stands in for the status byte). static_assert +/// reject buffers against this so a wrong key report can never truncate; +/// longer caller-supplied reasons are informational and sized by the caller. +static constexpr size_t MAC_FAILURE_PAYLOAD_SIZE = sizeof("Handshake MAC failure"); + +} // namespace esphome::noise +#endif // USE_NOISE diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp new file mode 100644 index 0000000000..6d426de012 --- /dev/null +++ b/esphome/components/noise/noise_handshake.cpp @@ -0,0 +1,139 @@ +#include "noise_handshake.h" +#ifdef USE_NOISE +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::noise { + +static const char *const TAG = "noise"; + +// Log the failing noise-c call at the same verbosity the api helper used +// before this class existed; callers only see one collapsed error code. +#define HANDSHAKE_STEP_LOG(func_name, err_code) \ + ESP_LOGVV(TAG, "%s failed: %s", LOG_STR_ARG(LOG_STR(func_name)), LOG_STR_ARG(noise_err_to_logstr(err_code))) + +NoiseResponderHandshake::~NoiseResponderHandshake() { + if (this->handshake_ != nullptr) { + noise_handshakestate_free(this->handshake_); + this->handshake_ = nullptr; + } +} + +int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) { + if (this->handshake_ != nullptr) { + noise_handshakestate_free(this->handshake_); + this->handshake_ = nullptr; + } + // Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack: + // noise_handshakestate_new_by_id copies it, so a member would waste + // 104 bytes per connection, and a static const would sit in RAM on + // ESP8266 (.rodata is DRAM there). + const NoiseProtocolId nid = { + .prefix_id = NOISE_PREFIX_STANDARD, + .pattern_id = NOISE_PATTERN_NN, + .modifier_ids = {NOISE_MODIFIER_PSK0}, + .dh_id = NOISE_DH_CURVE25519, + .cipher_id = NOISE_CIPHER_CHACHAPOLY, + .hash_id = NOISE_HASH_SHA256, + .hybrid_id = NOISE_DH_NONE, + }; + + int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER); + if (err != 0) { + HANDSHAKE_STEP_LOG("noise_handshakestate_new_by_id", err); + return err; + } + err = noise_handshakestate_set_pre_shared_key(this->handshake_, psk.data(), psk.size()); + if (err != 0) { + HANDSHAKE_STEP_LOG("noise_handshakestate_set_pre_shared_key", err); + return this->fail_init_(err); + } + err = noise_handshakestate_set_prologue(this->handshake_, prologue, prologue_len); + if (err != 0) { + HANDSHAKE_STEP_LOG("noise_handshakestate_set_prologue", err); + return this->fail_init_(err); + } + err = noise_handshakestate_start(this->handshake_); + if (err != 0) { + HANDSHAKE_STEP_LOG("noise_handshakestate_start", err); + return this->fail_init_(err); + } + return 0; +} + +/// Release a half-initialized state so a failed init() leaves the object as +/// if init() was never called. +int NoiseResponderHandshake::fail_init_(int err) { + noise_handshakestate_free(this->handshake_); + this->handshake_ = nullptr; + return err; +} + +NoiseResponderHandshake::Action NoiseResponderHandshake::action() const { + if (this->handshake_ == nullptr) { + // A caller bug: init() was never called, or split() already released the state + ESP_LOGVV(TAG, "action() on uninitialized or split handshake"); + return Action::ACTION_FAILED; + } + int raw = noise_handshakestate_get_action(this->handshake_); + switch (raw) { + case NOISE_ACTION_READ_MESSAGE: + return Action::ACTION_READ; + case NOISE_ACTION_WRITE_MESSAGE: + return Action::ACTION_WRITE; + case NOISE_ACTION_SPLIT: + return Action::ACTION_SPLIT; + default: + // Preserve the raw code in debug logs; callers only see the collapsed enum + ESP_LOGVV(TAG, "Unexpected noise action %d", raw); + return Action::ACTION_FAILED; + } +} + +int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) { + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_input(mbuf, data, len); + return noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr); +} + +int NoiseResponderHandshake::write_message(uint8_t *out, size_t capacity, size_t &out_len) { + out_len = 0; + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_output(mbuf, out, capacity); + int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr); + if (err == 0) + out_len = mbuf.size; + return err; +} + +int NoiseResponderHandshake::split(NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher) { + // Defined error postcondition: noise-c leaves the out-params unwritten on + // its early error returns, so a caller passing uninitialized locals must + // never see garbage to free + send_cipher = nullptr; + recv_cipher = nullptr; + int err = noise_handshakestate_split(this->handshake_, &send_cipher, &recv_cipher); + if (err != 0) + return err; + noise_handshakestate_free(this->handshake_); + this->handshake_ = nullptr; + return 0; +} + +extern "C" { +// noise-c's only randomness source (the vendored library compiles no rand of +// its own); HWRNG backed. Lives in this TU so every handshake consumer links +// it and the definition can never be dropped from the archive. +void noise_rand_bytes(void *output, size_t len) { + if (!esphome::random_bytes(reinterpret_cast(output), len)) { + ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting"); + arch_restart(); + } +} +} + +} // namespace esphome::noise +#endif // USE_NOISE diff --git a/esphome/components/noise/noise_handshake.h b/esphome/components/noise/noise_handshake.h new file mode 100644 index 0000000000..30596f35c2 --- /dev/null +++ b/esphome/components/noise/noise_handshake.h @@ -0,0 +1,63 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_NOISE +#include +#include + +#include + +#include "noise.h" + +namespace esphome::noise { + +/** Sans-IO responder side of a Noise_NNpsk0_25519_ChaChaPoly_SHA256 handshake. + * + * Owns only the noise-c handshake state; the caller moves the raw handshake + * messages (no framing) over its own transport, driven by action(): + * read_message() while READ, write_message() while WRITE, then split() to + * take ownership of the transport ciphers. All methods return a noise-c + * error code, 0 on success. Called outside their action() step (before + * init(), after split()) the message methods return a noise-c error rather + * than crashing; the library checks its state argument. + * + * Methods are deliberately small separate functions so callers on tight + * stacks (RP2040 core0 scratch bank) never pay for more than one branch; + * the curve25519 step alone needs ~2KB of stack. + */ +class NoiseResponderHandshake { + public: + // The ACTION_ prefix is macro-collision safety: SDK headers #define bare + // names like READ/WRITE, and macros expand even inside an enum class. + enum class Action : uint8_t { ACTION_READ, ACTION_WRITE, ACTION_SPLIT, ACTION_FAILED }; + + NoiseResponderHandshake() = default; + ~NoiseResponderHandshake(); + // Owns a raw noise-c handshake state; copying would double free it + NoiseResponderHandshake(const NoiseResponderHandshake &) = delete; + NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete; + + /// Create and start the handshake with the given PSK and prologue. A + /// repeated call frees the previous handshake state and starts over. + [[nodiscard]] int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len); + /// ACTION_FAILED is the catch-all: returned before init(), after split() + /// has released the state, and when noise-c reports a failed handshake. + [[nodiscard]] Action action() const; + /// Process one received handshake message. The buffer is consumed in + /// place: noise-c decrypts into it and zeroes it before returning. + [[nodiscard]] int read_message(uint8_t *data, size_t len); + /// Produce the next handshake message into out; out_len receives its size + /// and is zero on error. + [[nodiscard]] int write_message(uint8_t *out, size_t capacity, size_t &out_len); + /// Hand out the transport ciphers and free the handshake state. The caller + /// owns both cipher states and must free them with noise_cipherstate_free(); + /// both are set to nullptr on error. + [[nodiscard]] int split(NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher); + + protected: + int fail_init_(int err); + + NoiseHandshakeState *handshake_{nullptr}; +}; + +} // namespace esphome::noise +#endif // USE_NOISE diff --git a/esphome/components/zephyr/library.py b/esphome/components/zephyr/library.py index 0e6551ccf1..b339ae45b0 100644 --- a/esphome/components/zephyr/library.py +++ b/esphome/components/zephyr/library.py @@ -28,6 +28,7 @@ from esphome.platformio.library import ( collect_filtered_files, convert_libraries, ensure_list, + lex_build_flags, split_list_by_condition, ) @@ -80,7 +81,11 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str: build_include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR) build_src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER)) - build_flags = ensure_list(build.get("flags", DEFAULT_BUILD_FLAGS)) + # The shared lexer re-glues spaced entries and drops bare/empty + # arguments, same as the espidf emitter + build_flags = lex_build_flags( + build.get("flags", DEFAULT_BUILD_FLAGS), component.name + ) src_files = collect_filtered_files( read_path / Path(build_src_dir), build_src_filter diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 20aca3776f..5f34437145 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -222,6 +222,7 @@ #define API_MAX_SEND_QUEUE 8 #define MAX_API_CONNECTIONS 6 #define USE_MD5 +#define USE_NOISE #define USE_SHA256 #ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 #define USE_MQTT diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index 4655d1c54d..105413cf44 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -58,8 +58,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: """ def escape_entry(p: PathType) -> str: - # In CMakeLists.txt, backslashes need to be escaped - return f'"{str(p)}"'.replace("\\", "\\\\") + # In CMakeLists.txt, backslashes and embedded quotes need escaping + # (a quoted define value reaches here via the shlex round-trip) + escaped = str(p).replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' def escape_path(p: PathType) -> str: # CMake uses forward slashes for paths on every platform and treats diff --git a/esphome/platformio/extra_script.py b/esphome/platformio/extra_script.py index e60a50d746..e04ccd1f55 100644 --- a/esphome/platformio/extra_script.py +++ b/esphome/platformio/extra_script.py @@ -33,7 +33,7 @@ def apply_extra_script( """Run a library's ``extraScript`` and fold its captured env vars into ``build.flags``; ``board_mcu`` is a callable so it resolves lazily.""" extra_script = component.data.get("build", {}).get("extraScript") - if not extra_script: + if extra_script is None or extra_script == "": return if not isinstance(extra_script, str): # A list/dict value would raise an opaque TypeError on the join below @@ -170,6 +170,15 @@ class _FakeSConsEnv: ) return self._vars.get(key, default) + def __contains__(self, key: object) -> bool: + # Without this, "KEY" in env falls back to the legacy sequence + # protocol: __getitem__(0), (1), ... never raises, so it loops + # forever flooding the log + return key in self._vars + + def __iter__(self): + return iter(self._vars) + def __getitem__(self, key: str) -> str: # Scripts also read env["BOARD_MCU"]; an unmodelled subscript # degrades one branch instead of discarding the whole capture diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index bd16736dfb..853dae4d6c 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -687,15 +687,15 @@ def dependency_is_usable( def _valid_dependency_entry(entry: dict, manifest_name: str) -> bool: - """Whether a normalized entry carries a usable name (non-empty string) - and version (string, if present); invalid entries warn naming the - manifest.""" + """Whether a normalized entry carries a usable name (non-empty string), + version (string, if present), and owner (string, if present); invalid + entries warn naming the manifest.""" name = entry.get("name") - if ( - isinstance(name, str) - and name - and ("version" not in entry or isinstance(entry["version"], str)) - ): + owner = entry.get("owner") + name_ok = isinstance(name, str) and name + version_ok = "version" not in entry or isinstance(entry["version"], str) + owner_ok = owner is None or isinstance(owner, str) + if name_ok and version_ok and owner_ok: return True _LOGGER.warning( "Ignoring unrecognized dependency entry %r of %s", entry, manifest_name @@ -714,7 +714,7 @@ def normalize_dependencies( so callers see a uniform list. ``manifest_name`` names the manifest in the warning for entries that cannot be normalized. """ - if not dependencies: + if dependencies is None: return [] if isinstance(dependencies, str): # A plain string is one or more comma-separated names; iterating it @@ -1126,11 +1126,19 @@ def convert_libraries( f"library.properties in {source_dir}" ) - if not isinstance(component.data, dict) or not isinstance( - component.data.get("build", {}), dict - ): - # A bare json.load imposes no shape; every backend dereferences - # data/build, so validate once here and name the library + # A bare json.load imposes no shape; every backend dereferences + # these fields, so validate once here and name the library + malformed = not isinstance(component.data, dict) + if not malformed: + build = component.data.get("build", {}) + malformed = ( + not isinstance(build, dict) + or not isinstance(component.data.get(ESPHOME_DATA_KEY, {}), dict) + or not isinstance(build.get("srcDir", ""), str) + or not isinstance(build.get("includeDir", ""), str) + or not isinstance(build.get("srcFilter", ""), (str, list)) + ) + if malformed: raise EsphomeError(f"Library {key} has a malformed manifest") warn_properties_depends(component.name, component.data) @@ -1140,10 +1148,12 @@ def convert_libraries( # An explicitly requested library fails fast; the routine # cross-platform skip stays at debug, other causes warn if key in top_level_keys: - raise RuntimeError( - f"Requested library {key} is not compatible with " - f"{backend.framework}: {e}" - ) from e + reason = ( + f"is not compatible with {backend.framework}" + if isinstance(e, IncompatiblePlatform) + else "has a malformed manifest" + ) + raise RuntimeError(f"Requested library {key} {reason}: {e}") from e if isinstance(e, IncompatiblePlatform): _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) else: diff --git a/platformio.ini b/platformio.ini index 4c372cc0bb..b2a36e687c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.21 ; api + esphome/noise-c@0.1.21 ; noise (api, ota) improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.21 ; api + esphome/noise-c@0.1.21 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.21 ; used by api + esphome/noise-c@0.1.21 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0565bc5330..12c078911c 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -3,8 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - # api must run its to_code to define USE_API, USE_API_PLAINTEXT, - # and add the noise-c library dependency. + # api must run its to_code to define USE_API and USE_API_NOISE. The + # AUTO_LOADed noise component runs its own to_code via the override in + # tests/benchmarks/components/noise/__init__.py. manifest.enable_codegen() original_to_code = manifest.to_code diff --git a/tests/benchmarks/components/noise/__init__.py b/tests/benchmarks/components/noise/__init__.py new file mode 100644 index 0000000000..f430e9dd36 --- /dev/null +++ b/tests/benchmarks/components/noise/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code must run: it defines USE_NOISE and adds the noise-c library + # the api benchmark sources need. + manifest.enable_codegen() diff --git a/tests/component_tests/noise/__init__.py b/tests/component_tests/noise/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/noise/test_encryption_key.py b/tests/component_tests/noise/test_encryption_key.py new file mode 100644 index 0000000000..62abae6487 --- /dev/null +++ b/tests/component_tests/noise/test_encryption_key.py @@ -0,0 +1,37 @@ +"""Tests for the shared noise encryption key helpers.""" + +from __future__ import annotations + +import pytest + +from esphome import config_validation as cv +from esphome.components.noise import decode_encryption_key, validate_encryption_key + +KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + + +def test_validate_encryption_key_roundtrips() -> None: + assert validate_encryption_key(KEY) == KEY + + +@pytest.mark.parametrize("value", ["not-base64!!!", "AAECAw=="]) +def test_validate_encryption_key_rejects_bad_input(value: str) -> None: + with pytest.raises(cv.Invalid): + validate_encryption_key(value) + + +def test_decode_encryption_key_returns_32_bytes() -> None: + assert decode_encryption_key(KEY) == bytes(range(32)) + + +def test_decode_encryption_key_rejects_invalid_base64() -> None: + """The shared helper raises cv.Invalid, not binascii.Error.""" + with pytest.raises(cv.Invalid, match="base64"): + decode_encryption_key("A") + + +def test_decode_encryption_key_rejects_short_decode() -> None: + """a2b_base64 stops at embedded padding; a short decode must not become + a zero padded PSK on the device.""" + with pytest.raises(cv.Invalid, match="32 bytes"): + decode_encryption_key("AAECAw==") diff --git a/tests/components/noise/__init__.py b/tests/components/noise/__init__.py new file mode 100644 index 0000000000..60a5740a83 --- /dev/null +++ b/tests/components/noise/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code must run: it defines USE_NOISE and adds the noise-c library + # the component sources under test need. + manifest.enable_codegen() diff --git a/tests/components/noise/common.yaml b/tests/components/noise/common.yaml new file mode 100644 index 0000000000..35253a35e6 --- /dev/null +++ b/tests/components/noise/common.yaml @@ -0,0 +1 @@ +noise: diff --git a/tests/components/noise/test.esp32-idf.yaml b/tests/components/noise/test.esp32-idf.yaml new file mode 100644 index 0000000000..550ffd1f88 --- /dev/null +++ b/tests/components/noise/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + noise: !include common.yaml diff --git a/tests/components/noise/test.esp8266-ard.yaml b/tests/components/noise/test.esp8266-ard.yaml new file mode 100644 index 0000000000..550ffd1f88 --- /dev/null +++ b/tests/components/noise/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + noise: !include common.yaml diff --git a/tests/components/noise/test.host.yaml b/tests/components/noise/test.host.yaml new file mode 100644 index 0000000000..550ffd1f88 --- /dev/null +++ b/tests/components/noise/test.host.yaml @@ -0,0 +1,2 @@ +packages: + noise: !include common.yaml diff --git a/tests/components/noise/test.rp2040-ard.yaml b/tests/components/noise/test.rp2040-ard.yaml new file mode 100644 index 0000000000..550ffd1f88 --- /dev/null +++ b/tests/components/noise/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + noise: !include common.yaml diff --git a/tests/components/noise/test_noise_handshake.cpp b/tests/components/noise/test_noise_handshake.cpp new file mode 100644 index 0000000000..d879a26c43 --- /dev/null +++ b/tests/components/noise/test_noise_handshake.cpp @@ -0,0 +1,199 @@ +#include + +#include + +#include + +#include "esphome/components/noise/noise.h" +#include "esphome/components/noise/noise_handshake.h" + +namespace esphome::noise::testing { + +using Action = NoiseResponderHandshake::Action; + +// A raw noise-c initiator driving the same Noise_NNpsk0_25519_ChaChaPoly_SHA256 +// pattern the responder class implements, so the tests exercise a real +// two-message handshake rather than mirrored calls into the class under test. +class Initiator { + public: + Initiator(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) { + const NoiseProtocolId nid = { + .prefix_id = NOISE_PREFIX_STANDARD, + .pattern_id = NOISE_PATTERN_NN, + .modifier_ids = {NOISE_MODIFIER_PSK0}, + .dh_id = NOISE_DH_CURVE25519, + .cipher_id = NOISE_CIPHER_CHACHAPOLY, + .hash_id = NOISE_HASH_SHA256, + .hybrid_id = NOISE_DH_NONE, + }; + EXPECT_EQ(noise_handshakestate_new_by_id(&this->state_, &nid, NOISE_ROLE_INITIATOR), 0); + EXPECT_EQ(noise_handshakestate_set_pre_shared_key(this->state_, psk.data(), psk.size()), 0); + EXPECT_EQ(noise_handshakestate_set_prologue(this->state_, prologue, prologue_len), 0); + EXPECT_EQ(noise_handshakestate_start(this->state_), 0); + } + ~Initiator() { + if (this->state_ != nullptr) + noise_handshakestate_free(this->state_); + if (this->send_ != nullptr) + noise_cipherstate_free(this->send_); + if (this->recv_ != nullptr) + noise_cipherstate_free(this->recv_); + } + Initiator(const Initiator &) = delete; + Initiator &operator=(const Initiator &) = delete; + + size_t write_message(uint8_t *out, size_t capacity) { + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_output(mbuf, out, capacity); + EXPECT_EQ(noise_handshakestate_write_message(this->state_, &mbuf, nullptr), 0); + return mbuf.size; + } + + int read_message(uint8_t *data, size_t len) { + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_input(mbuf, data, len); + return noise_handshakestate_read_message(this->state_, &mbuf, nullptr); + } + + void split() { EXPECT_EQ(noise_handshakestate_split(this->state_, &this->send_, &this->recv_), 0); } + + NoiseCipherState *send_{nullptr}; + NoiseCipherState *recv_{nullptr}; + + private: + NoiseHandshakeState *state_{nullptr}; +}; + +static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'}; + +static psk_t make_psk(uint8_t seed) { + psk_t psk; + for (size_t i = 0; i < psk.size(); i++) { + psk[i] = static_cast(seed + i); + } + return psk; +} + +TEST(NoiseResponderHandshakeTest, ActionFailedBeforeInit) { + NoiseResponderHandshake handshake; + EXPECT_EQ(handshake.action(), Action::ACTION_FAILED); +} + +TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) { + // The class doc promises a noise-c error, not a crash, when the message + // methods run outside their action() step; pin the library's null check + NoiseResponderHandshake handshake; + uint8_t buf[MAX_HANDSHAKE_SIZE] = {}; + size_t out_len = 0; + EXPECT_NE(handshake.read_message(buf, sizeof(buf)), 0); + EXPECT_NE(handshake.write_message(buf, sizeof(buf), out_len), 0); + // Deliberately non-null: split() documents a nullptr postcondition on + // error, so a caller's uninitialized locals never hold garbage to free + auto *sentinel = reinterpret_cast(0x1); + NoiseCipherState *send_cipher = sentinel; + NoiseCipherState *recv_cipher = sentinel; + EXPECT_NE(handshake.split(send_cipher, recv_cipher), 0); + EXPECT_EQ(send_cipher, nullptr); + EXPECT_EQ(recv_cipher, nullptr); +} + +TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) { + const psk_t psk = make_psk(7); + NoiseResponderHandshake responder; + ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0); + EXPECT_EQ(responder.action(), Action::ACTION_READ); + + Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE)); + uint8_t msg[MAX_HANDSHAKE_SIZE]; + size_t msg_len = initiator.write_message(msg, sizeof(msg)); + ASSERT_GT(msg_len, 0u); + + ASSERT_EQ(responder.read_message(msg, msg_len), 0); + ASSERT_EQ(responder.action(), Action::ACTION_WRITE); + + size_t reply_len = 0; + ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0); + ASSERT_GT(reply_len, 0u); + ASSERT_EQ(responder.action(), Action::ACTION_SPLIT); + + ASSERT_EQ(initiator.read_message(msg, reply_len), 0); + initiator.split(); + + NoiseCipherState *send_cipher = nullptr; + NoiseCipherState *recv_cipher = nullptr; + ASSERT_EQ(responder.split(send_cipher, recv_cipher), 0); + ASSERT_NE(send_cipher, nullptr); + ASSERT_NE(recv_cipher, nullptr); + // The handshake state is released by split(); the class reports FAILED after + EXPECT_EQ(responder.action(), Action::ACTION_FAILED); + EXPECT_EQ(static_cast(noise_cipherstate_get_mac_length(send_cipher)), MAC_SIZE); + + // Responder encrypts, initiator decrypts + uint8_t frame[64]; + static constexpr char PLAINTEXT[] = "encrypted ota"; + std::memcpy(frame, PLAINTEXT, sizeof(PLAINTEXT)); + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_inout(mbuf, frame, sizeof(PLAINTEXT), sizeof(frame)); + ASSERT_EQ(noise_cipherstate_encrypt(send_cipher, &mbuf), 0); + EXPECT_EQ(mbuf.size, sizeof(PLAINTEXT) + MAC_SIZE); + + noise_buffer_set_inout(mbuf, frame, mbuf.size, sizeof(frame)); + ASSERT_EQ(noise_cipherstate_decrypt(initiator.recv_, &mbuf), 0); + ASSERT_EQ(mbuf.size, sizeof(PLAINTEXT)); + EXPECT_EQ(std::memcmp(frame, PLAINTEXT, sizeof(PLAINTEXT)), 0); + + noise_cipherstate_free(send_cipher); + noise_cipherstate_free(recv_cipher); +} + +TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) { + // The documented retry shape: a repeated init() frees the previous state + // and starts over. The first message under the new key authenticating + // proves the restart took effect; the old state surviving would fail the + // MAC here. + NoiseResponderHandshake responder; + ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0); + ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0); + EXPECT_EQ(responder.action(), Action::ACTION_READ); + + Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE)); + uint8_t msg[MAX_HANDSHAKE_SIZE]; + size_t msg_len = initiator.write_message(msg, sizeof(msg)); + ASSERT_GT(msg_len, 0u); + EXPECT_EQ(responder.read_message(msg, msg_len), 0); +} + +TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) { + NoiseResponderHandshake responder; + ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0); + + Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE)); + uint8_t msg[MAX_HANDSHAKE_SIZE]; + size_t msg_len = initiator.write_message(msg, sizeof(msg)); + ASSERT_GT(msg_len, 0u); + + int err = responder.read_message(msg, msg_len); + EXPECT_EQ(err, NOISE_ERROR_MAC_FAILURE); + EXPECT_EQ(responder.action(), Action::ACTION_FAILED); +} + +TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) { + // The prologue binds the plaintext preamble for downgrade resistance; a + // tampered preamble must fail even with the right key. + const psk_t psk = make_psk(7); + NoiseResponderHandshake responder; + ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0); + + static const uint8_t TAMPERED[] = {'x'}; + Initiator initiator(psk, TAMPERED, sizeof(TAMPERED)); + uint8_t msg[MAX_HANDSHAKE_SIZE]; + size_t msg_len = initiator.write_message(msg, sizeof(msg)); + ASSERT_GT(msg_len, 0u); + + EXPECT_EQ(responder.read_message(msg, msg_len), NOISE_ERROR_MAC_FAILURE); +} + +} // namespace esphome::noise::testing diff --git a/tests/components/noise/test_noise_primitives.cpp b/tests/components/noise/test_noise_primitives.cpp new file mode 100644 index 0000000000..018be9f717 --- /dev/null +++ b/tests/components/noise/test_noise_primitives.cpp @@ -0,0 +1,74 @@ +#include + +#include + +#include + +#include "esphome/components/noise/noise.h" + +namespace esphome::noise::testing { + +TEST(NoiseContextTest, AllZerosPskIsReserved) { + psk_t zeros{}; + EXPECT_TRUE(NoiseContext::is_all_zeros(zeros)); + + psk_t psk{}; + psk[31] = 1; + EXPECT_FALSE(NoiseContext::is_all_zeros(psk)); + + NoiseContext ctx; + EXPECT_FALSE(ctx.has_psk()); + ctx.set_psk(zeros); + EXPECT_FALSE(ctx.has_psk()); + ctx.set_psk(psk); + EXPECT_TRUE(ctx.has_psk()); + EXPECT_EQ(ctx.get_psk(), psk); +} + +TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) { + uint8_t header[FRAME_HEADER_SIZE]; + write_frame_header(header, 0x1234); + EXPECT_EQ(header[0], FRAME_INDICATOR); + EXPECT_EQ(header[1], 0x12); + EXPECT_EQ(header[2], 0x34); +} + +TEST(WireFormatTest, RejectPayloadCarriesStatusByteAndMacFailureContract) { + // The MAC failure string is a wire contract: clients match it to report a + // wrong key. Format the payload exactly the way the handshake read path does. + uint8_t buf[64]; + size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE)); + static constexpr char EXPECTED[] = "Handshake MAC failure"; + ASSERT_EQ(len, 1 + strlen(EXPECTED)); + EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT); + EXPECT_EQ(memcmp(buf + 1, EXPECTED, strlen(EXPECTED)), 0); + // The exported floor covers the full MAC failure payload exactly + EXPECT_EQ(MAC_FAILURE_PAYLOAD_SIZE, 1 + strlen(EXPECTED)); + + // Any other error maps to the generic reason + len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_INVALID_STATE)); + static constexpr char GENERIC[] = "Handshake error"; + ASSERT_EQ(len, 1 + strlen(GENERIC)); + EXPECT_EQ(memcmp(buf + 1, GENERIC, strlen(GENERIC)), 0); +} + +TEST(WireFormatTest, RejectPayloadTruncatesToCapacity) { + uint8_t buf[8]; + size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE)); + ASSERT_EQ(len, sizeof(buf)); + EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT); + EXPECT_EQ(memcmp(buf + 1, "Handsha", 7), 0); + + // A one-byte buffer still carries the status byte + uint8_t tiny[1]; + len = format_reject_payload(tiny, sizeof(tiny), reject_reason_for(NOISE_ERROR_MAC_FAILURE)); + ASSERT_EQ(len, 1u); + EXPECT_EQ(tiny[0], HANDSHAKE_STATUS_REJECT); + + // A zero-capacity buffer yields no payload and stays untouched + uint8_t none[1] = {0xAA}; + EXPECT_EQ(format_reject_payload(none, 0, reject_reason_for(NOISE_ERROR_MAC_FAILURE)), 0u); + EXPECT_EQ(none[0], 0xAA); +} + +} // namespace esphome::noise::testing diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 957c98f8ad..a7d871b2f8 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -294,6 +294,19 @@ def test_generate_cmakelists_txt_multi_token_flag(tmp_component): assert ' "-include"\n "cp_custom_alloc.h"\n' in content +def test_generate_cmakelists_txt_escapes_embedded_quotes(tmp_component): + """A define value carrying a literal quote survives into CMake as an + escaped quote, not a prematurely-terminated string.""" + src_dir = tmp_component.path / "src" + src_dir.mkdir() + (src_dir / "main.c").write_text("int main() {}") + # shlex keeps the backslash-escaped quotes as literal characters + tmp_component.data = {"build": {"flags": ['-DMSG=\\"hi\\"']}} + + content = generate_cmakelists_txt(tmp_component) + assert '"-DMSG=\\"hi\\""' in content + + def test_generate_cmakelists_txt_extra_script_link_flags(tmp_component): """Captured extra-script LINKFLAGS come out as target_link_options, not compile options where they would be silently ineffective.""" diff --git a/tests/unit_tests/test_platformio_extra_script.py b/tests/unit_tests/test_platformio_extra_script.py index 980ce29ccb..07f0e78500 100644 --- a/tests/unit_tests/test_platformio_extra_script.py +++ b/tests/unit_tests/test_platformio_extra_script.py @@ -461,6 +461,27 @@ def test_prepend_inserts_ahead_of_existing(method: str) -> None: assert env.result.libs == ["algobsec", "bsec", "m"] +def test_env_membership_and_iteration(tmp_path) -> None: + """Membership tests and for-loops must use the mapping protocol; the + legacy sequence fallback through __getitem__ would loop forever.""" + env = _FakeSConsEnv( + board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266" + ) + assert "BOARD_MCU" in env + assert "NOPE" not in env + assert sorted(env) == ["BOARD_MCU", "PIOENV", "PIOPLATFORM"] + + +def test_apply_extra_script_non_string_falsey_raises(tmp_path) -> None: + """A falsey non-string extraScript (false, 0, []) is a malformed + manifest, not an absent script.""" + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": False}} + with pytest.raises(EsphomeError, match="must be a string"): + apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266") + + def test_env_get_unknown_key_warns_once(caplog) -> None: """A script branching on an unmodelled env var is diagnosable.""" env = _FakeSConsEnv( diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index adfb30201a..d69f2b1200 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -800,10 +800,28 @@ def test_normalize_dependencies_forms(caplog) -> None: assert normalize_dependencies({"Foo": ["1.0", "2.0"]}, "libx") == [] assert normalize_dependencies([{"name": "Foo", "version": 1}], "libx") == [] assert caplog.text.count("unrecognized dependency entry") == 7 + # A non-string owner would stringify into a malformed registry name + assert ( + normalize_dependencies( + [{"name": "Foo", "owner": {"bad": 1}, "version": "1.0"}], "libx" + ) + == [] + ) + # A falsey scalar (0, false) is malformed, not an empty list + assert normalize_dependencies(0, "libx") == [] + assert "Ignoring unrecognized dependencies 0 of libx" in caplog.text @pytest.mark.parametrize( - "manifest", [["not", "a", "manifest"], {"name": "A", "build": "src"}] + "manifest", + [ + ["not", "a", "manifest"], + {"name": "A", "build": "src"}, + {"name": "A", "ESPHOME": "yes"}, + {"name": "A", "build": {"srcDir": 123}}, + {"name": "A", "build": {"includeDir": ["inc"]}}, + {"name": "A", "build": {"srcFilter": {"+": "src"}}}, + ], ) def test_convert_libraries_malformed_manifest_raises( tmp_path, monkeypatch, manifest diff --git a/tests/unit_tests/test_zephyr_library.py b/tests/unit_tests/test_zephyr_library.py index b370fe0c47..0d899ec91d 100644 --- a/tests/unit_tests/test_zephyr_library.py +++ b/tests/unit_tests/test_zephyr_library.py @@ -66,6 +66,22 @@ def test_generate_cmakelists_txt_flags_and_includes(tmp_path): assert "-lm" in out +def test_generate_cmakelists_txt_lexes_spaced_flags(tmp_path): + """A spaced -I entry routes to include dirs instead of landing verbatim + in compile options; same shared lexer as the espidf emitter.""" + c = _make_component(tmp_path) + (tmp_path / "src").mkdir() + (tmp_path / "src" / "a.c").write_text("") + (tmp_path / "include").mkdir() + c.data = {"build": {"flags": "-I include -DBAR=1"}} + + out = generate_cmakelists_txt(c) + + assert str((tmp_path / "include").resolve()).replace("\\", "\\\\") in out + assert "-DBAR=1" in out + assert "-I include" not in out + + def test_generate_zephyr_modules_collects_all_dirs_and_writes(tmp_path, monkeypatch): # Two converted libraries: one top-level, one transitive dependency. The # converter calls backend.emit for both; generate_zephyr_modules must return