diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 3568318dad..6202e127bf 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -14,6 +14,7 @@ from esphome.components.noise import ( # noqa: F401 ENCRYPTION_SCHEMA, decode_encryption_key, encryption_schema, + new_psk_progmem, validate_encryption_key, ) from esphome.config_helpers import filter_source_files_from_defines, get_logger_level @@ -589,8 +590,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 = decode_encryption_key(key) - cg.add(var.set_noise_psk(list(decoded))) + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 138dbdddba..29b2858aee 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -548,7 +548,7 @@ 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 = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size()); + int err = this->handshake_.init(this->ctx_, 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; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 43d35363d3..f9069ab5c0 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -583,11 +583,14 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString } bool APIServer::load_and_apply_noise_psk_() { - SavedNoisePsk saved{}; - if (!this->noise_pref_.load(&saved)) +#ifdef USE_API_NOISE_PSK_FROM_YAML + return false; +#else + if (!this->noise_pref_.load(&this->saved_psk_)) return false; - this->set_noise_psk(saved.psk); + this->noise_ctx_.set_psk(this->saved_psk_.psk.data()); return true; +#endif } bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) { @@ -597,8 +600,7 @@ bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) { ESP_LOGW(TAG, "Key set in YAML"); return false; #else - auto &old_psk = this->noise_ctx_.get_psk(); - if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) { + if (this->saved_psk_.psk == psk) { ESP_LOGW(TAG, "New PSK matches old"); return true; } diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 146ff09381..d5d9a357aa 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -78,7 +78,8 @@ class APIServer final : public Component, #ifdef USE_API_NOISE bool save_noise_psk(noise::psk_t psk, bool make_active = true); bool clear_noise_psk(bool make_active = true); - void set_noise_psk(const noise::psk_t &psk) { this->noise_ctx_.set_psk(psk); } + /// psk points at 32 bytes that live in flash for the life of the program + void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; } #endif // USE_API_NOISE @@ -358,6 +359,9 @@ class APIServer final : public Component, #ifdef USE_API_NOISE noise::NoiseContext noise_ctx_; +#ifndef USE_API_NOISE_PSK_FROM_YAML + SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key +#endif ESPPreferenceObject noise_pref_; #endif // USE_API_NOISE }; diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 6559000229..f2c37155ae 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -1,11 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components.noise import ( - decode_encryption_key, - encryption_schema, - is_reserved_key, -) +from esphome.components.noise import encryption_schema, is_reserved_key, new_psk_progmem from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.config_helpers import filter_source_files_from_defines, merge_config import esphome.config_validation as cv @@ -25,7 +21,7 @@ from esphome.const import ( CONF_VERSION, CONF_WEB_SERVER, ) -from esphome.core import CORE, ID, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv from esphome.types import ConfigType @@ -41,7 +37,8 @@ DEPENDENCIES = ["network"] def AUTO_LOAD(config: ConfigType) -> list[str]: - """Auto-load noise only when encryption is configured.""" + """Auto-load noise only when encryption is configured. The api key offer + path inherits noise from the api component's own AUTO_LOAD.""" base = ["sha256", "socket"] # A falsy config is a tooling probe for the maximal set (None from # dependency resolution, {} from the components-graph platform probe); @@ -309,13 +306,7 @@ async def to_code(config: ConfigType) -> None: key = _api_static_key(CORE.config.get(CONF_API) or {}) if key is not None: cg.add_define("USE_OTA_ENCRYPTION") - # The key stays in flash; it is copied into the handshake only while - # an encrypted session is open - psk = cg.progmem_array( - ID(f"{config[CONF_ID].id}_psk", is_declaration=True, type=cg.uint8), - list(decode_encryption_key(key)), - ) - cg.add(var.set_noise_psk(psk)) + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], 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.h b/esphome/components/esphome/ota/ota_esphome.h index 9f7f6ae649..847ee34d40 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -46,7 +46,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #ifdef USE_OTA_ENCRYPTION /// psk points at 32 bytes that live in flash for the life of the program - void set_noise_psk(const uint8_t *psk) { this->noise_psk_ = psk; } + void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } #endif /// Manually set the port OTA should listen on @@ -146,7 +146,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { std::unique_ptr auth_buf_; #endif // USE_OTA_PASSWORD #ifdef USE_OTA_ENCRYPTION - const uint8_t *noise_psk_{nullptr}; + noise::NoiseContext noise_ctx_; std::unique_ptr noise_; #endif // USE_OTA_ENCRYPTION diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 4e2abe4416..a9ed552211 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -65,15 +65,8 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { *p++ = ota::OTA_RESPONSE_FEATURE_FLAGS; *p++ = server_feature_flags; - // noise-c keeps its own copy of the key, so the flash copy is only read here - noise::psk_t psk; -#ifdef USE_ESP8266 - memcpy_P(psk.data(), this->noise_psk_, psk.size()); -#else - std::memcpy(psk.data(), this->noise_psk_, psk.size()); -#endif - int err = - this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY : this->noise_->handshake.init(psk, prologue, sizeof(prologue)); + int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY + : this->noise_->handshake.init(this->noise_ctx_, prologue, sizeof(prologue)); if (err != 0) { ESP_LOGW(TAG, "Session init: %d", err); this->cleanup_connection_(); diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 0f9328a482..15b62a8ca0 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -5,6 +5,8 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_KEY +from esphome.core import ID +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -61,6 +63,15 @@ ENCRYPTION_SCHEMA = cv.Schema( ) +def new_psk_progmem(parent_id: ID, key: str) -> MockObj: + """Emit the decoded key as a PROGMEM array; the component keeps a pointer + so the key never occupies RAM.""" + return cg.progmem_array( + ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), + list(decode_encryption_key(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 diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index 7c79e98098..54c1bb315b 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -15,9 +15,25 @@ namespace esphome::noise { static const char *const TAG = "noise"; -void NoiseContext::set_psk(const psk_t &psk) { +void NoiseContext::set_psk(const uint8_t *psk) { this->psk_ = psk; - this->has_psk_ = !is_all_zeros(psk); + psk_t copy; + this->load_psk(copy); + if (is_all_zeros(copy)) { + this->psk_ = nullptr; + } +} + +void NoiseContext::load_psk(psk_t &out) const { + if (this->psk_ == nullptr) { + out.fill(0); + return; + } +#ifdef USE_ESP8266 + memcpy_P(out.data(), this->psk_, out.size()); +#else + std::memcpy(out.data(), this->psk_, out.size()); +#endif } const LogString *noise_err_to_logstr(int err) { diff --git a/esphome/components/noise/noise.h b/esphome/components/noise/noise.h index 1e779f0380..9472414bbe 100644 --- a/esphome/components/noise/noise.h +++ b/esphome/components/noise/noise.h @@ -23,13 +23,16 @@ class NoiseContext { } return acc == 0; } - void set_psk(const psk_t &psk); - const psk_t &get_psk() const { return this->psk_; } - bool has_psk() const { return this->has_psk_; } + /// psk points at 32 bytes that outlive the context: a PROGMEM array from + /// codegen, or RAM owned by the caller for a runtime provisioned key. + /// The all-zeros key counts as no key. + void set_psk(const uint8_t *psk); + /// Copy the key out (flash-aware on ESP8266); all zeros when none is set. + void load_psk(psk_t &out) const; + bool has_psk() const { return this->psk_ != nullptr; } protected: - psk_t psk_{}; - bool has_psk_{false}; + const uint8_t *psk_{nullptr}; }; /// Convert a noise error code to a readable error diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index 6d426de012..cc7fa603c4 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -20,7 +20,7 @@ NoiseResponderHandshake::~NoiseResponderHandshake() { } } -int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) { +int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len) { if (this->handshake_ != nullptr) { noise_handshakestate_free(this->handshake_); this->handshake_ = nullptr; @@ -44,6 +44,9 @@ int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, siz HANDSHAKE_STEP_LOG("noise_handshakestate_new_by_id", err); return err; } + // noise-c keeps its own copy, so the key only passes through the stack here + psk_t psk; + ctx.load_psk(psk); 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); diff --git a/esphome/components/noise/noise_handshake.h b/esphome/components/noise/noise_handshake.h index 30596f35c2..bf1aa8cb7f 100644 --- a/esphome/components/noise/noise_handshake.h +++ b/esphome/components/noise/noise_handshake.h @@ -36,9 +36,9 @@ class NoiseResponderHandshake { 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); + /// Create and start the handshake with the context's PSK and the prologue. + /// A repeated call frees the previous handshake state and starts over. + [[nodiscard]] int init(const NoiseContext &ctx, 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; diff --git a/esphome/espota2.py b/esphome/espota2.py index 40adb4527a..2d490caf99 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -539,10 +539,11 @@ def perform_ota( "offer encryption; refusing to send the image in plaintext. " "The running firmware was built before OTA encryption " "(ESPHome 2026.9.0) or without an 'api: encryption: key'. " - "Firmware built with an api key offers encryption: install " - "once with the 'ota: encryption:' block removed, then restore " - "the block and install again. Otherwise flash by serial or " - "the web_server OTA platform." + "If the config has an 'api: encryption: key', install once " + "with the 'ota: encryption:' block removed (that firmware " + "offers encryption), then restore the block and install " + "again. Otherwise flash by serial or the web_server OTA " + "platform." ) # The prologue binds every negotiation byte both sides saw, so any # tampering with the plaintext preamble breaks the handshake. diff --git a/tests/components/noise/test_noise_handshake.cpp b/tests/components/noise/test_noise_handshake.cpp index d879a26c43..f2081f2965 100644 --- a/tests/components/noise/test_noise_handshake.cpp +++ b/tests/components/noise/test_noise_handshake.cpp @@ -68,6 +68,14 @@ class Initiator { static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'}; +// The context only points at the key and init() copies it before returning, +// so a temporary context over a temporary key is safe within one call +static NoiseContext ctx_for(const psk_t &psk) { + NoiseContext ctx; + ctx.set_psk(psk.data()); + return ctx; +} + static psk_t make_psk(uint8_t seed) { psk_t psk; for (size_t i = 0; i < psk.size(); i++) { @@ -102,7 +110,7 @@ TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) { TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) { const psk_t psk = make_psk(7); NoiseResponderHandshake responder; - ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0); + ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0); EXPECT_EQ(responder.action(), Action::ACTION_READ); Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE)); @@ -155,8 +163,8 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) { // 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); + ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0); + ASSERT_EQ(responder.init(ctx_for(make_psk(9)), PROLOGUE, sizeof(PROLOGUE)), 0); EXPECT_EQ(responder.action(), Action::ACTION_READ); Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE)); @@ -168,7 +176,7 @@ TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) { TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) { NoiseResponderHandshake responder; - ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0); + ASSERT_EQ(responder.init(ctx_for(make_psk(7)), PROLOGUE, sizeof(PROLOGUE)), 0); Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE)); uint8_t msg[MAX_HANDSHAKE_SIZE]; @@ -185,7 +193,7 @@ TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) { // 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); + ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0); static const uint8_t TAMPERED[] = {'x'}; Initiator initiator(psk, TAMPERED, sizeof(TAMPERED)); diff --git a/tests/components/noise/test_noise_primitives.cpp b/tests/components/noise/test_noise_primitives.cpp index 018be9f717..2e89fbc682 100644 --- a/tests/components/noise/test_noise_primitives.cpp +++ b/tests/components/noise/test_noise_primitives.cpp @@ -17,12 +17,16 @@ TEST(NoiseContextTest, AllZerosPskIsReserved) { EXPECT_FALSE(NoiseContext::is_all_zeros(psk)); NoiseContext ctx; + psk_t loaded; EXPECT_FALSE(ctx.has_psk()); - ctx.set_psk(zeros); + ctx.load_psk(loaded); + EXPECT_EQ(loaded, zeros); + ctx.set_psk(zeros.data()); EXPECT_FALSE(ctx.has_psk()); - ctx.set_psk(psk); + ctx.set_psk(psk.data()); EXPECT_TRUE(ctx.has_psk()); - EXPECT_EQ(ctx.get_psk(), psk); + ctx.load_psk(loaded); + EXPECT_EQ(loaded, psk); } TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) { diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index 1f44c2253d..03158ae769 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -143,25 +143,13 @@ async def test_host_ota_encrypted( pid_before = proc.pid # A plaintext upload must be refused with the device unharmed - rc, _ = await loop.run_in_executor( - None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path - ) + rc = await _run_ota(ota_port, None, binary_path, None) assert rc == 1, "plaintext upload to an encrypted device must fail" await asyncio.sleep(0.5) assert proc.returncode is None, "process died on rejected plaintext OTA" # The encrypted upload goes through and the device re-execs - rc, _ = await loop.run_in_executor( - None, - functools.partial( - espota2.run_ota, - LOCALHOST, - ota_port, - None, - binary_path, - noise_psk=API_KEY, - ), - ) + rc = await _run_ota(ota_port, None, binary_path, API_KEY) assert rc == 0, "encrypted OTA reported failure" await asyncio.wait_for(rebooted, timeout=10.0) await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)