diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index d5f4efbc02..875b6f31ca 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -1,12 +1,16 @@ import logging import esphome.codegen as cg +from esphome.components.noise import decode_encryption_key, encryption_schema from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.config_helpers import merge_config import esphome.config_validation as cv from esphome.const import ( + CONF_API, + CONF_ENCRYPTION, CONF_ESPHOME, CONF_ID, + CONF_KEY, CONF_NUM_ATTEMPTS, CONF_OTA, CONF_PASSWORD, @@ -15,6 +19,7 @@ from esphome.const import ( CONF_REBOOT_TIMEOUT, CONF_SAFE_MODE, CONF_VERSION, + CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority @@ -30,7 +35,15 @@ CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] -AUTO_LOAD = ["sha256", "socket"] +def AUTO_LOAD(config: ConfigType) -> list[str]: + """Auto-load noise only when encryption is configured.""" + base = ["sha256", "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: + return base + ["noise"] + return base esphome = cg.esphome_ns.namespace("esphome") @@ -67,11 +80,24 @@ def ota_esphome_final_validate(config): CONF_PASSWORD in merged_ota_esphome_configs_by_port[conf_port] and CONF_PASSWORD in ota_conf and merged_ota_esphome_configs_by_port[conf_port][CONF_PASSWORD] - != ota_conf.get(CONF_PASSWORD) + != ota_conf[CONF_PASSWORD] ): raise cv.Invalid( f"Found multiple configurations but {CONF_PASSWORD} is inconsistent" ) + # Encryption blocks conflict only when both pin a key; a bare + # `encryption:` (a package/device split) is compatible with a + # keyed one, and merge_config yields the keyed result + merged_key = ( + merged_ota_esphome_configs_by_port[conf_port] + .get(CONF_ENCRYPTION, {}) + .get(CONF_KEY) + ) + other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) + if merged_key and other_key and merged_key != other_key: + raise cv.Invalid( + f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent" + ) ports_with_merged_configs.append(conf_port) merged_ota_esphome_configs_by_port[conf_port] = merge_config( @@ -94,6 +120,73 @@ def ota_esphome_final_validate(config): new_ota_conf.extend(merged_ota_esphome_configs_by_port.values()) + # There is one encryption key per device: when the api component has one, + # ota uses it, and an explicit ota key must match it. A bare `encryption:` + # block resolves to the api key here so both codegen and the upload CLI + # see the actual key. + api_conf = full_conf.get(CONF_API) or {} + api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) + has_web_server_ota = any( + conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf + ) + for ota_conf in merged_ota_esphome_configs_by_port.values(): + # Merging same-port blocks can combine a password from one block with + # encryption from another; re-check the exclusion on the merged result. + _validate_no_password_with_encryption(ota_conf) + if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is None: + continue + if has_web_server_ota: + # The web_server ota platform accepts the same image over plain + # HTTP with basic auth, a full bypass of the encryption. + if CONF_WEB_SERVER in full_conf: + # With the web_server component the endpoint is always on; + # fail closed like the password combination + raise cv.Invalid( + f"'{CONF_OTA}' {CONF_ENCRYPTION} cannot be combined with the " + f"'{CONF_WEB_SERVER}' component; its '{CONF_OTA}' platform " + f"accepts the same image over plaintext HTTP, remove one of them" + ) + # Without the component the platform is the captive_portal + # auto-load: the endpoint only exists while the fallback AP is + # active, so keep the recovery path and warn instead + _LOGGER.warning( + "OTA encryption does not cover the %s OTA platform (auto-loaded " + "by captive_portal); the plaintext /update endpoint stays " + "reachable while the fallback AP is active", + CONF_WEB_SERVER, + ) + if ota_key := encryption_conf.get(CONF_KEY): + if api_key and ota_key != api_key: + raise cv.Invalid( + f"'{CONF_OTA}' {CONF_ENCRYPTION} {CONF_KEY} must match the " + f"'{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY}; omit the " + f"'{CONF_OTA}' {CONF_KEY} to use the '{CONF_API}' one" + ) + elif not api_key: + if CONF_ENCRYPTION in api_conf: + # A keyless `api: encryption:` block gets its key provisioned + # at runtime and stored in flash, so there is nothing to + # inherit at build time + raise cv.Invalid( + f"the '{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY} is provisioned at " + f"runtime and cannot be inherited at build time; set an explicit " + f"'{CONF_OTA}' {CONF_ENCRYPTION} {CONF_KEY}" + ) + raise cv.Invalid( + f"'{CONF_OTA}' {CONF_ENCRYPTION} has no {CONF_KEY} and there is no " + f"'{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY} to inherit; set one of them" + ) + else: + encryption_conf[CONF_KEY] = api_key + # The device treats the all-zeros PSK as "no key configured" (it is the + # api provisioning sentinel), so letting it through would leave the OTA + # port accepting plaintext while the YAML says encryption. Fail closed. + if not any(decode_encryption_key(encryption_conf[CONF_KEY])): + raise cv.Invalid( + f"The all-zeros {CONF_KEY} is reserved and provides no protection; " + f"generate a real key with: openssl rand -base64 32" + ) + full_conf[CONF_OTA] = new_ota_conf fv.full_config.set(full_conf) @@ -107,6 +200,17 @@ def ota_esphome_final_validate(config): ) +# Not cv.has_at_most_one_key: this message explains the why, and the check is +# reused on merged same-port configs in final validate where schemas do not run +def _validate_no_password_with_encryption(config: ConfigType) -> ConfigType: + if CONF_PASSWORD in config and CONF_ENCRYPTION in config: + raise cv.Invalid( + f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the " + f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}'" + ) + return config + + def _consume_ota_sockets(config: ConfigType) -> ConfigType: """Register socket needs for OTA component.""" from esphome.components import socket @@ -134,6 +238,7 @@ CONFIG_SCHEMA = cv.All( ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.sensitive(), + cv.Optional(CONF_ENCRYPTION): encryption_schema, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" ), @@ -147,12 +252,24 @@ CONFIG_SCHEMA = cv.All( ) .extend(BASE_OTA_SCHEMA) .extend(cv.COMPONENT_SCHEMA), + _validate_no_password_with_encryption, _consume_ota_sockets, ) FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate +def FILTER_SOURCE_FILES() -> list[str]: + """Filter out the noise transport when no ota entry configures encryption.""" + for ota_conf in CORE.config.get(CONF_OTA, []): + if ( + ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME + and ota_conf.get(CONF_ENCRYPTION) is not None + ): + return [] + return ["ota_esphome_noise.cpp"] + + @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) @@ -171,6 +288,12 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ALLOW_PARTITION_ACCESS): cg.add_define("USE_OTA_PARTITIONS") + if (encryption_conf := config.get(CONF_ENCRYPTION)) is not None: + # A missing key was resolved from the api component in final validate. + key = encryption_conf[CONF_KEY] + cg.add_define("USE_OTA_ENCRYPTION") + cg.add(var.set_noise_psk(list(decode_encryption_key(key)))) + # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 74f84b71fb..e09036db58 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -27,7 +27,6 @@ namespace esphome { static const char *const TAG = "esphome.ota"; static constexpr uint16_t OTA_BLOCK_SIZE = 8192; -static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size for OTA data transfer static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer @@ -105,6 +104,11 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Password configured"); } #endif +#ifdef USE_OTA_ENCRYPTION + if (this->noise_ctx_.has_psk()) { + ESP_LOGCONFIG(TAG, " Encryption configured"); + } +#endif #ifdef USE_OTA_PARTITIONS ESP_LOGCONFIG(TAG, " Partition access allowed\n" @@ -148,8 +152,10 @@ void ESPHomeOTAComponent::loop() { static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04; void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. @@ -201,8 +207,7 @@ void ESPHomeOTAComponent::handle_handshake_() { } // Validate magic bytes - static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; - if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) { + if (memcmp(this->handshake_buf_, MAGIC_BYTES, sizeof(MAGIC_BYTES)) != 0) { ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0], this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]); this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_MAGIC); @@ -234,6 +239,19 @@ void ESPHomeOTAComponent::handle_handshake_() { } this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); + +#ifdef USE_OTA_ENCRYPTION + // Fail closed: with a PSK configured the client must negotiate encryption + // (which requires the extended protocol); refuse plaintext uploads. + static constexpr uint8_t NOISE_REQUIRED_FEATURES = + CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; + if (this->noise_ctx_.has_psk() && (this->ota_features_ & NOISE_REQUIRED_FEATURES) != NOISE_REQUIRED_FEATURES) { + ESP_LOGW(TAG, "Client does not support encryption"); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED); + return; + } +#endif + this->transition_ota_state_(OTAState::FEATURE_ACK); const bool supports_compression = @@ -249,6 +267,12 @@ void ESPHomeOTAComponent::handle_handshake_() { this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); #ifdef USE_OTA_PARTITIONS this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; +#endif +#ifdef USE_OTA_ENCRYPTION + if (this->noise_ctx_.has_psk()) { + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE; + } + this->server_feature_flags_ = this->handshake_buf_[1]; #endif } else { this->handshake_buf_[0] = @@ -264,6 +288,18 @@ void ESPHomeOTAComponent::handle_handshake_() { if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } +#ifdef USE_OTA_ENCRYPTION + // With a PSK configured the rest of the session runs inside the noise + // transport; the client sends the first handshake frame next, so there + // is nothing to do until data arrives. + if (this->noise_ctx_.has_psk()) { + if (!this->noise_start_session_()) { + return; + } + this->transition_ota_state_(OTAState::NOISE_HANDSHAKE); + return; + } +#endif #ifdef USE_OTA_PASSWORD // If password is set, move to auth phase if (!this->password_.empty()) { @@ -301,6 +337,16 @@ void ESPHomeOTAComponent::handle_handshake_() { this->handle_data_(); return; +#ifdef USE_OTA_ENCRYPTION + case OTAState::NOISE_HANDSHAKE: + if (!this->handle_noise_handshake_()) { + return; + } + this->transition_ota_state_(OTAState::DATA); + this->handle_data_(); + return; +#endif + default: break; } @@ -360,12 +406,13 @@ void ESPHomeOTAComponent::handle_data_() { this->client_->setblocking(true); // Acknowledge auth OK - 1 byte - this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); + this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK); if (this->extended_proto_) { // Read ota type, 1 byte - if (!this->readall_(buf, 1)) { + if (!this->data_readall_(buf, 1)) { this->log_read_error_(LOG_STR("OTA type")); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_type = static_cast(buf[0]); @@ -373,8 +420,9 @@ void ESPHomeOTAComponent::handle_data_() { ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); // Read size, 4 bytes MSB first - if (!this->readall_(buf, 4)) { + if (!this->data_readall_(buf, 4)) { this->log_read_error_(LOG_STR("size")); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } ota_size = (static_cast(buf[0]) << 24) | (static_cast(buf[1]) << 16) | @@ -404,11 +452,12 @@ void ESPHomeOTAComponent::handle_data_() { goto error; // NOLINT(cppcoreguidelines-avoid-goto) // Acknowledge prepare OK - 1 byte - this->write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK); + this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK); // Read binary MD5, 32 bytes - if (!this->readall_(buf, 32)) { + if (!this->data_readall_(buf, 32)) { this->log_read_error_(LOG_STR("MD5 checksum")); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } sbuf[32] = '\0'; @@ -416,7 +465,7 @@ void ESPHomeOTAComponent::handle_data_() { this->backend_->set_update_md5(sbuf); // Acknowledge MD5 OK - 1 byte - this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); + this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); // Track when we last received data so a silently-vanished peer (no FIN/RST // delivered, e.g. uploader killed mid-transfer or NAT/router dropped state) @@ -432,19 +481,37 @@ void ESPHomeOTAComponent::handle_data_() { } size_t remaining = ota_size - total; size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; - ssize_t read = this->client_->read(buf, requested); - if (read == -1) { - const int err = errno; - if (this->would_block_(err)) { - // read() already waited up to SO_RCVTIMEO for data, just feed WDT - App.feed_wdt(); - continue; + ssize_t read; +#ifdef USE_OTA_ENCRYPTION + if (this->noise_ != nullptr) { + // One frame per call; noise_read_data_ waits internally (readall_), so + // there is no would-block retry here and failures are already logged. + read = this->noise_read_data_(buf, requested); + if (read <= 0) { + // error_code still holds the last OK; report a real failure instead + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + } else +#endif + { + read = this->client_->read(buf, requested); + if (read == -1) { + const int err = errno; + if (this->would_block_(err)) { + // read() already waited up to SO_RCVTIMEO for data, just feed WDT + App.feed_wdt(); + continue; + } + ESP_LOGW(TAG, "Read err %d", err); + // error_code still holds the last OK; report a real failure instead + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } else if (read == 0) { + ESP_LOGW(TAG, "Remote closed"); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - ESP_LOGW(TAG, "Read err %d", err); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) } last_data_ms = millis(); @@ -456,7 +523,7 @@ void ESPHomeOTAComponent::handle_data_() { total += read; #if USE_OTA_VERSION == 2 while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) { - this->write_byte_(ota::OTA_RESPONSE_CHUNK_OK); + this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK); size_acknowledged += OTA_BLOCK_SIZE; } #endif @@ -475,7 +542,7 @@ void ESPHomeOTAComponent::handle_data_() { } // Acknowledge receive OK - 1 byte - this->write_byte_(ota::OTA_RESPONSE_RECEIVE_OK); + this->data_write_byte_(ota::OTA_RESPONSE_RECEIVE_OK); error_code = this->backend_->end(); if (error_code != ota::OTA_RESPONSE_OK) { @@ -484,10 +551,10 @@ void ESPHomeOTAComponent::handle_data_() { } // Acknowledge Update end OK - 1 byte - this->write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK); + this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK); // Read ACK - if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { + if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { this->log_read_error_(LOG_STR("ack")); // do not go to error, this is not fatal } @@ -510,7 +577,7 @@ void ESPHomeOTAComponent::handle_data_() { App.safe_reboot(); error: - this->write_byte_(static_cast(error_code)); + this->data_write_byte_(static_cast(error_code)); // Abort backend before cleanup - cleanup_connection_() destroys the backend. // Always call abort() unconditionally: backends register external partitions before @@ -677,6 +744,9 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->backend_ = nullptr; #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); +#endif +#ifdef USE_OTA_ENCRYPTION + this->noise_ = nullptr; #endif // Intentionally no disable_loop() — letting loop() run one more iteration catches // any connection that queued on the listener mid-session (otherwise the wake flag, diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 979e3f2d7d..8a57a99c53 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -4,6 +4,9 @@ #ifdef USE_OTA #include "esphome/components/ota/ota_backend_factory.h" #include "esphome/components/socket/socket.h" +#ifdef USE_OTA_ENCRYPTION +#include "esphome/components/noise/noise_handshake.h" +#endif #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" @@ -24,7 +27,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { AUTH_SEND, // Sending authentication request AUTH_READ, // Reading authentication data #endif // USE_OTA_PASSWORD - DATA, // BLOCKING! Processing OTA data (update, etc.) +#ifdef USE_OTA_ENCRYPTION + NOISE_HANDSHAKE, // Exchanging Noise handshake frames +#endif + DATA, // BLOCKING! Processing OTA data (update, etc.) }; #ifdef USE_OTA_PASSWORD void set_auth_password(const std::string &password) { password_ = password; } @@ -38,6 +44,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { } #endif // USE_OTA_PASSWORD +#ifdef USE_OTA_ENCRYPTION + void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); } +#endif + /// Manually set the port OTA should listen on void set_port(uint16_t port) { this->port_ = port; } @@ -63,6 +73,48 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { bool writeall_(const uint8_t *buf, size_t len); inline bool write_byte_(uint8_t byte) { return this->writeall_(&byte, 1); } +#ifdef USE_OTA_ENCRYPTION + // Heap-allocated only while an encrypted OTA session is active. + struct NoiseSession { + ~NoiseSession(); + noise::NoiseResponderHandshake handshake; + NoiseCipherState *send_cipher{nullptr}; + NoiseCipherState *recv_cipher{nullptr}; + uint16_t frame_len{0}; // total frame size once the header is parsed, 0 until then + uint16_t frame_pos{0}; // bytes read or written so far + bool writing{false}; // a produced handshake frame is still being flushed + uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE]; + }; + bool noise_start_session_(); + bool handle_noise_handshake_(); + bool noise_try_read_frame_(); + bool noise_try_write_frame_(); + void noise_send_reject_(const LogString *reason); + ssize_t noise_decrypt_(uint8_t *buf, size_t len); + ssize_t noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext); + bool noise_readall_(uint8_t *buf, size_t len); + ssize_t noise_read_data_(uint8_t *buf, size_t capacity); + bool noise_write_byte_(uint8_t byte); +#endif // USE_OTA_ENCRYPTION + + // Data-phase I/O dispatch: through the noise transport when a session is + // active, straight to the socket otherwise. + inline bool data_write_byte_(uint8_t byte) { +#ifdef USE_OTA_ENCRYPTION + if (this->noise_ != nullptr) + return this->noise_write_byte_(byte); +#endif + return this->write_byte_(byte); + } + // When encrypted, buf must have room for len + noise::MAC_SIZE bytes. + inline bool data_readall_(uint8_t *buf, size_t len) { +#ifdef USE_OTA_ENCRYPTION + if (this->noise_ != nullptr) + return this->noise_readall_(buf, len); +#endif + return this->readall_(buf, len); + } + bool try_read_(size_t to_read, const LogString *desc); bool try_write_(size_t to_write, const LogString *desc); @@ -91,6 +143,11 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::string password_; std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD +#ifdef USE_OTA_ENCRYPTION + noise::NoiseContext noise_ctx_; + std::unique_ptr noise_; + uint8_t server_feature_flags_{0}; // as sent in the feature ack, bound into the prologue +#endif // USE_OTA_ENCRYPTION socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; @@ -98,6 +155,15 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { uint32_t client_connect_time_{0}; static constexpr size_t HANDSHAKE_BUF_SIZE = 5; + // Buffer size for OTA data transfer. The upload client derives its maximum + // encrypted frame plaintext from this (espota2.NOISE_MAX_PLAINTEXT is this + // minus the 16-byte MAC); both must change together. + static constexpr size_t OTA_BUFFER_SIZE = 1024; +#ifdef USE_OTA_ENCRYPTION + // Shrinking the buffer would reject every frame a current CLI sends + static_assert(OTA_BUFFER_SIZE >= 1008 + noise::MAC_SIZE, "OTA_BUFFER_SIZE must fit a full encrypted data frame"); +#endif + static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; #ifdef USE_OTA_PARTITIONS uint32_t running_app_offset_{0}; size_t running_app_size_{0}; diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp new file mode 100644 index 0000000000..fbba031353 --- /dev/null +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -0,0 +1,274 @@ +#include "ota_esphome.h" +#ifdef USE_OTA +#ifdef USE_OTA_ENCRYPTION +#include "esphome/components/noise/noise.h" +#include "esphome/components/ota/ota_backend.h" +#include "esphome/core/log.h" + +#include +#include + +#ifdef USE_ESP8266 +#include +#endif + +namespace esphome { + +static const char *const TAG = "esphome.ota"; + +#ifdef USE_ESP8266 +static constexpr char OTA_NOISE_PROLOGUE_INIT[] PROGMEM = "NoiseOTAInit"; +#else +static const char *const OTA_NOISE_PROLOGUE_INIT = "NoiseOTAInit"; +#endif +static constexpr size_t OTA_NOISE_PROLOGUE_INIT_LEN = 12; // strlen("NoiseOTAInit") + +ESPHomeOTAComponent::NoiseSession::~NoiseSession() { + if (this->send_cipher != nullptr) { + noise_cipherstate_free(this->send_cipher); + } + if (this->recv_cipher != nullptr) { + noise_cipherstate_free(this->recv_cipher); + } +} + +/** Allocate the session and start the responder handshake. + * + * The prologue binds the whole plaintext preamble, so any tampering with the + * negotiation (a stripped feature flag, a changed version) breaks the first + * handshake MAC on either side: + * "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags + */ +bool ESPHomeOTAComponent::noise_start_session_() { + // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) + this->noise_ = std::unique_ptr(new (std::nothrow) NoiseSession()); + if (this->noise_ == nullptr) { + ESP_LOGW(TAG, "Session allocation failed"); + this->cleanup_connection_(); + return false; + } + + uint8_t prologue[OTA_NOISE_PROLOGUE_INIT_LEN + 5 + 2 + 1 + 2]; +#ifdef USE_ESP8266 + memcpy_P(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN); +#else + std::memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN); +#endif + uint8_t *p = prologue + OTA_NOISE_PROLOGUE_INIT_LEN; + // Magic bytes, already validated in MAGIC_READ + std::memcpy(p, MAGIC_BYTES, sizeof(MAGIC_BYTES)); + p += sizeof(MAGIC_BYTES); + // Our magic ack + *p++ = ota::OTA_RESPONSE_OK; + *p++ = USE_OTA_VERSION; + // The feature byte the client sent + *p++ = this->ota_features_; + // The feature ack we sent (noise requires the extended protocol) + *p++ = ota::OTA_RESPONSE_FEATURE_FLAGS; + *p++ = this->server_feature_flags_; + + int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue)); + if (err != 0) { + ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + this->cleanup_connection_(); + return false; + } + return true; +} + +/** Drive the non-blocking handshake from loop(); returns true once the + * transport ciphers are ready and the session can enter the data phase. + * On failure the connection is cleaned up and false is returned. + */ +bool ESPHomeOTAComponent::handle_noise_handshake_() { + NoiseSession &s = *this->noise_; + while (true) { + if (s.writing) { + if (!this->noise_try_write_frame_()) { + return false; // would block, or errored and cleaned up + } + s.writing = false; + s.frame_pos = 0; + s.frame_len = 0; + } + switch (s.handshake.action()) { + case noise::NoiseResponderHandshake::Action::ACTION_READ: { + if (!this->noise_try_read_frame_()) { + return false; + } + const uint16_t payload_len = s.frame_len - noise::FRAME_HEADER_SIZE; + s.frame_pos = 0; + s.frame_len = 0; + if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) { + ESP_LOGW(TAG, "Bad handshake error byte: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]); + this->cleanup_connection_(); + return false; + } + int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1); + if (err != 0) { + ESP_LOGW(TAG, "Handshake read: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + this->noise_send_reject_(noise::reject_reason_for(err)); + this->cleanup_connection_(); + return false; + } + break; + } + case noise::NoiseResponderHandshake::Action::ACTION_WRITE: { + size_t msg_len = 0; + int err = + s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len); + if (err != 0) { + ESP_LOGW(TAG, "Handshake write: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + this->cleanup_connection_(); + return false; + } + const uint16_t payload_len = msg_len + 1; + noise::write_frame_header(s.frame_buf, payload_len); + s.frame_buf[noise::FRAME_HEADER_SIZE] = noise::HANDSHAKE_STATUS_OK; + s.frame_len = noise::FRAME_HEADER_SIZE + payload_len; + s.frame_pos = 0; + s.writing = true; + break; + } + case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: { + int err = s.handshake.split(s.send_cipher, s.recv_cipher); + if (err != 0) { + ESP_LOGW(TAG, "Handshake split: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + this->cleanup_connection_(); + return false; + } + ESP_LOGD(TAG, "Noise handshake complete"); + return true; + } + default: { + ESP_LOGW(TAG, "Bad handshake state"); + this->cleanup_connection_(); + return false; + } + } + } +} + +/// Non-blocking read of one handshake frame into the session buffer. +bool ESPHomeOTAComponent::noise_try_read_frame_() { + NoiseSession &s = *this->noise_; + while (s.frame_pos < noise::FRAME_HEADER_SIZE) { + ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, noise::FRAME_HEADER_SIZE - s.frame_pos); + if (!this->handle_read_error_(read, LOG_STR("read noise header"))) { + return false; + } + s.frame_pos += read; + } + if (s.frame_len == 0) { + const uint16_t payload_len = encode_uint16(s.frame_buf[1], s.frame_buf[2]); + if (s.frame_buf[0] != noise::FRAME_INDICATOR || payload_len < 1 || payload_len > 1 + noise::MAX_HANDSHAKE_SIZE) { + ESP_LOGW(TAG, "Bad handshake frame: 0x%02X, %u bytes", s.frame_buf[0], payload_len); + this->cleanup_connection_(); + return false; + } + s.frame_len = noise::FRAME_HEADER_SIZE + payload_len; + } + while (s.frame_pos < s.frame_len) { + ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos); + if (!this->handle_read_error_(read, LOG_STR("read noise frame"))) { + return false; + } + s.frame_pos += read; + } + return true; +} + +/// Non-blocking write of the pending session-buffer frame. +bool ESPHomeOTAComponent::noise_try_write_frame_() { + NoiseSession &s = *this->noise_; + while (s.frame_pos < s.frame_len) { + ssize_t written = this->client_->write(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos); + if (!this->handle_write_error_(written, LOG_STR("write noise frame"))) { + return false; + } + s.frame_pos += written; + } + return true; +} + +/// Best-effort explicit reject frame so the client can log a readable reason. +void ESPHomeOTAComponent::noise_send_reject_(const LogString *reason) { + uint8_t data[noise::FRAME_HEADER_SIZE + 1 + 32]; + static_assert(sizeof(data) - noise::FRAME_HEADER_SIZE >= noise::MAC_FAILURE_PAYLOAD_SIZE, + "reject buffer must fit the MAC failure wire contract"); + const size_t payload_len = + noise::format_reject_payload(data + noise::FRAME_HEADER_SIZE, sizeof(data) - noise::FRAME_HEADER_SIZE, reason); + noise::write_frame_header(data, payload_len); + this->client_->write(data, noise::FRAME_HEADER_SIZE + payload_len); // Best effort, non-blocking +} + +/// Decrypt a ciphertext in place; returns the plaintext size or -1. +ssize_t ESPHomeOTAComponent::noise_decrypt_(uint8_t *buf, size_t len) { + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_inout(mbuf, buf, len, len); + int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf); + if (err != 0) { + ESP_LOGW(TAG, "Decrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + return -1; + } + return mbuf.size; +} + +/** Blocking read of one frame whose ciphertext size must be within the given + * bounds, decrypted in place; returns the plaintext size, or -1 on error. + * buf needs max_ciphertext capacity. + */ +ssize_t ESPHomeOTAComponent::noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext) { + uint8_t header[noise::FRAME_HEADER_SIZE]; + if (!this->readall_(header, sizeof(header))) { + return -1; + } + const size_t ciphertext_len = encode_uint16(header[1], header[2]); + if (header[0] != noise::FRAME_INDICATOR || ciphertext_len < min_ciphertext || ciphertext_len > max_ciphertext) { + ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], ciphertext_len); + return -1; + } + if (!this->readall_(buf, ciphertext_len)) { + return -1; + } + return this->noise_decrypt_(buf, ciphertext_len); +} + +/** Blocking read of one frame whose plaintext must be exactly len bytes + * (control units are one unit per frame). buf needs len + noise::MAC_SIZE + * capacity; the plaintext lands at buf[0..len). + */ +bool ESPHomeOTAComponent::noise_readall_(uint8_t *buf, size_t len) { + return this->noise_read_frame_blocking_(buf, len + noise::MAC_SIZE, len + noise::MAC_SIZE) == (ssize_t) len; +} + +/** Blocking read of one data-phase frame, decrypted in place; returns the + * plaintext size, or -1 on error. buf is the OTA_BUFFER_SIZE data buffer. + * The ciphertext must fit that buffer and its plaintext must fit what the + * caller accepts (the remaining image bytes). + */ +ssize_t ESPHomeOTAComponent::noise_read_data_(uint8_t *buf, size_t capacity) { + const size_t max_ciphertext = std::min(capacity + noise::MAC_SIZE, OTA_BUFFER_SIZE); + return this->noise_read_frame_blocking_(buf, noise::MAC_SIZE + 1, max_ciphertext); +} + +/// Blocking write of one response byte as an encrypted frame. +bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) { + uint8_t frame[noise::FRAME_HEADER_SIZE + 1 + noise::MAC_SIZE]; + frame[noise::FRAME_HEADER_SIZE] = byte; + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE); + int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf); + if (err != 0) { + ESP_LOGW(TAG, "Encrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); + return false; + } + noise::write_frame_header(frame, mbuf.size); + return this->writeall_(frame, noise::FRAME_HEADER_SIZE + mbuf.size); +} + +} // namespace esphome +#endif // USE_OTA_ENCRYPTION +#endif // USE_OTA diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 1c24fc320a..7348a0ce90 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -49,6 +49,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91, OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, + OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5f34437145..6b4f88c08c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -235,6 +235,7 @@ #define USE_RUNTIME_IMAGE_JPEG #define USE_RUNTIME_STATS #define USE_OTA +#define USE_OTA_ENCRYPTION #define USE_OTA_PASSWORD #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE diff --git a/tests/components/ota/encryption.yaml b/tests/components/ota/encryption.yaml new file mode 100644 index 0000000000..550d35caec --- /dev/null +++ b/tests/components/ota/encryption.yaml @@ -0,0 +1,9 @@ +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome + port: 3288 + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" diff --git a/tests/components/ota/encryption_inherit.yaml b/tests/components/ota/encryption_inherit.yaml new file mode 100644 index 0000000000..15ada6f810 --- /dev/null +++ b/tests/components/ota/encryption_inherit.yaml @@ -0,0 +1,12 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + port: 3289 + encryption: diff --git a/tests/components/ota/test-encryption.esp32-idf.yaml b/tests/components/ota/test-encryption.esp32-idf.yaml new file mode 100644 index 0000000000..000e38168e --- /dev/null +++ b/tests/components/ota/test-encryption.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include encryption.yaml diff --git a/tests/components/ota/test-encryption.esp8266-ard.yaml b/tests/components/ota/test-encryption.esp8266-ard.yaml new file mode 100644 index 0000000000..000e38168e --- /dev/null +++ b/tests/components/ota/test-encryption.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include encryption.yaml diff --git a/tests/components/ota/test-encryption.rp2040-ard.yaml b/tests/components/ota/test-encryption.rp2040-ard.yaml new file mode 100644 index 0000000000..000e38168e --- /dev/null +++ b/tests/components/ota/test-encryption.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include encryption.yaml diff --git a/tests/components/ota/test-encryption_inherit.esp8266-ard.yaml b/tests/components/ota/test-encryption_inherit.esp8266-ard.yaml new file mode 100644 index 0000000000..71aa083e7e --- /dev/null +++ b/tests/components/ota/test-encryption_inherit.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include encryption_inherit.yaml