From 826571518acd50e3194fa390f629f3ac58fedd79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Oct 2026 11:15:31 -0500 Subject: [PATCH] [noise] Generate the responder ephemeral key ahead of the handshake (#19000) --- esphome/components/api/__init__.py | 2 + esphome/components/api/api_connection.h | 6 ++ esphome/components/api/api_server.cpp | 23 ++++++- esphome/components/api/api_server.h | 5 +- esphome/components/noise/__init__.py | 5 ++ esphome/components/noise/noise.cpp | 34 ++++++++++ esphome/components/noise/noise.h | 18 +++++ esphome/components/noise/noise_handshake.cpp | 7 ++ esphome/components/noise/noise_handshake.h | 3 +- esphome/core/defines.h | 1 + tests/components/noise/__init__.py | 8 +++ .../components/noise/test_noise_handshake.cpp | 66 +++++++++++++++++++ 12 files changed, 174 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 2291dc54f8..40f520a057 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -13,6 +13,7 @@ from esphome.components.logger import request_log_listener from esphome.components.noise import ( # noqa: F401 ENCRYPTION_SCHEMA, decode_encryption_key, + enable_spare_ephemeral, encryption_schema, new_psk_progmem, validate_encryption_key, @@ -613,6 +614,7 @@ 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") + enable_spare_ephemeral() else: cg.add_define("USE_API_PLAINTEXT") diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index f03c4c42e8..0b8a1dc2b2 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -317,9 +317,15 @@ class APIConnection final : public APIServerConnectionBase { void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg); #endif + // How long a new connection holds off the spare ephemeral refill + static constexpr uint32_t CONNECT_GRACE_MS = 1000; bool is_authenticated() { return static_cast(this->flags_.connection_state) == ConnectionState::AUTHENTICATED; } + // An older unauthenticated connection is a stale half open client and does not count + bool is_still_connecting(uint32_t now) { + return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS; + } bool is_connection_setup() { return static_cast(this->flags_.connection_state) == ConnectionState::CONNECTED || this->is_authenticated(); diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 2a89d31a74..da9ca8bc79 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -162,6 +162,13 @@ void APIServer::loop() { this->accept_new_connections_(); } + const bool connected = network::is_connected(); +#ifdef USE_NOISE_SPARE_EPHEMERAL + if (connected && !noise::has_spare_ephemeral()) { + this->refill_spare_ephemeral_(); + } +#endif + if (this->api_connection_count_ == 0) { // Check reboot timeout - done in loop to avoid scheduler heap churn // (cancelled scheduler items sit in heap memory until their scheduled time). @@ -178,8 +185,7 @@ void APIServer::loop() { } // Process clients and remove disconnected ones in a single pass - // Check network connectivity once for all clients - if (!network::is_connected()) { + if (!connected) { // Network is down - disconnect all clients for (auto &client : this->active_clients()) { client->on_fatal_error(); @@ -207,6 +213,19 @@ void APIServer::loop() { } } +#ifdef USE_NOISE_SPARE_EPHEMERAL +// An OTA handshake is not visible here and just pays the refill it triggered +void APIServer::refill_spare_ephemeral_() { + const uint32_t now = App.get_loop_component_start_time(); + for (auto &client : this->active_clients()) { + if (client->is_still_connecting(now)) { + return; + } + } + noise::prepare_spare_ephemeral(); +} +#endif + void APIServer::remove_client_(uint8_t client_index) { auto &client = this->clients_[client_index]; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 49cf77ca5a..865cd17156 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -5,7 +5,7 @@ #include "api_buffer.h" // Must precede clients_ so APIConnection is complete for default_delete (libc++). #include "api_connection.h" -#ifdef USE_API_NOISE +#if defined(USE_API_NOISE) || defined(USE_NOISE_SPARE_EPHEMERAL) // Only present in the build when the noise component is loaded #include "esphome/components/noise/noise.h" #endif @@ -367,6 +367,9 @@ class APIServer final : public Component uint8_t provisioning_source_{0}; #endif +#ifdef USE_NOISE_SPARE_EPHEMERAL + void refill_spare_ephemeral_(); +#endif #ifdef USE_API_NOISE noise::NoiseContext noise_ctx_; #ifndef USE_API_NOISE_PSK_FROM_YAML diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 47cd4cfc67..c894c147cd 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -92,6 +92,11 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: return ENCRYPTION_SCHEMA(config) +def enable_spare_ephemeral() -> None: + """Compile the spare ephemeral key slot; the component that refills it calls this.""" + cg.add_define("USE_NOISE_SPARE_EPHEMERAL") + + async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") cg.add_library("esphome/noise-c", "0.1.30") diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index 86ea1ec874..118bfb234f 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -1,6 +1,7 @@ #include "noise.h" #ifdef USE_NOISE #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -8,6 +9,7 @@ #include #include +#include namespace esphome::noise { @@ -21,6 +23,38 @@ void NoiseContext::load_psk(psk_t &out) const { progmem_memcpy(out.data(), this->psk_, out.size()); } +#ifdef USE_NOISE_SPARE_EPHEMERAL +static constexpr size_t PRIVATE_KEY_SIZE = SPARE_EPHEMERAL_KEY_SIZE; +static constexpr size_t PUBLIC_KEY_SIZE = SPARE_EPHEMERAL_KEY_SIZE; +uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +void prepare_spare_ephemeral() { + uint8_t *private_key = spare_ephemeral; + uint8_t *public_key = spare_ephemeral + PRIVATE_KEY_SIZE; + // Same steps as noise-c's keygen; the clamp sets the ready bit + if (!random_bytes(private_key, PRIVATE_KEY_SIZE)) { + sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral)); + return; + } + private_key[0] &= 0xF8; + private_key[PRIVATE_KEY_SIZE - 1] = (private_key[PRIVATE_KEY_SIZE - 1] & 0x7F) | 0x40; + if (crypto_scalarmult_curve25519_base(public_key, private_key) != 0) { + sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral)); + } +} + +int consume_spare_ephemeral(NoiseHandshakeState *state) { + if (!has_spare_ephemeral()) { + return 0; + } + // noise-c keeps its own copy, so the slot is wiped either way + int err = noise_handshakestate_set_local_ephemeral(state, spare_ephemeral, PRIVATE_KEY_SIZE, + spare_ephemeral + PRIVATE_KEY_SIZE, PUBLIC_KEY_SIZE); + sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral)); + return err; +} +#endif // USE_NOISE_SPARE_EPHEMERAL + const LogString *noise_err_to_logstr(int err) { if (err == NOISE_ERROR_NO_MEMORY) return LOG_STR("NO_MEMORY"); diff --git a/esphome/components/noise/noise.h b/esphome/components/noise/noise.h index 1033d5423c..271a829944 100644 --- a/esphome/components/noise/noise.h +++ b/esphome/components/noise/noise.h @@ -6,6 +6,9 @@ #include #include "esphome/core/log.h" +// Forward decl from +using NoiseHandshakeState = struct NoiseHandshakeState_s; + namespace esphome::noise { using psk_t = std::array; @@ -38,6 +41,21 @@ class NoiseContext { /// Convert a noise error code to a readable error const LogString *noise_err_to_logstr(int err); +#ifdef USE_NOISE_SPARE_EPHEMERAL +// One responder ephemeral key pair made ahead of time (about 60 ms on ESP8266), refilled by the api server +// while idle and consumed by the next noise handshake of any transport; empty means the handshake generates +// its own. Private key then public key; the private key stays in RAM until consumed. +static constexpr size_t SPARE_EPHEMERAL_KEY_SIZE = 32; +static constexpr size_t SPARE_EPHEMERAL_SIZE = 2 * SPARE_EPHEMERAL_KEY_SIZE; +extern uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// The clamp always sets bit 254, so it doubles as the ready flag +inline bool has_spare_ephemeral() { return (spare_ephemeral[SPARE_EPHEMERAL_KEY_SIZE - 1] & 0x40) != 0; } +/// Fill the slot; blocks for the base point multiply +void prepare_spare_ephemeral(); +/// Move the key pair into a handshake that has not started and wipe the slot; 0 unless noise-c rejects it +int consume_spare_ephemeral(NoiseHandshakeState *state); +#endif + // 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 diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index 64846dcb67..bd1afa198d 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -57,6 +57,13 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog HANDSHAKE_STEP_LOG("noise_handshakestate_set_prologue", err); return this->fail_init_(err); } +#ifdef USE_NOISE_SPARE_EPHEMERAL + err = consume_spare_ephemeral(this->handshake_); + // Not fatal: the handshake generates its own key instead + if (err != 0) { + HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", err); + } +#endif err = noise_handshakestate_start(this->handshake_); if (err != 0) { HANDSHAKE_STEP_LOG("noise_handshakestate_start", err); diff --git a/esphome/components/noise/noise_handshake.h b/esphome/components/noise/noise_handshake.h index bf1aa8cb7f..ef556a26b0 100644 --- a/esphome/components/noise/noise_handshake.h +++ b/esphome/components/noise/noise_handshake.h @@ -37,7 +37,8 @@ class NoiseResponderHandshake { NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete; /// Create and start the handshake with the context's PSK and the prologue. - /// A repeated call frees the previous handshake state and starts over. + /// A repeated call frees the previous handshake state and starts over. A + /// spare ephemeral key, when one is ready, is used instead of generating. [[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. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f845154389..88f35dac46 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -281,6 +281,7 @@ #define USE_IMPROV_NEXT_URL #define USE_MD5 #define USE_NOISE +#define USE_NOISE_SPARE_EPHEMERAL #define USE_SHA256 #ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 #define USE_MQTT diff --git a/tests/components/noise/__init__.py b/tests/components/noise/__init__.py index 60a5740a83..aeae988f44 100644 --- a/tests/components/noise/__init__.py +++ b/tests/components/noise/__init__.py @@ -1,3 +1,4 @@ +import esphome.codegen as cg from tests.testing_helpers import ComponentManifestOverride @@ -5,3 +6,10 @@ 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() + real_to_code = manifest.to_code + + async def to_code_testing(config): + await real_to_code(config) + cg.add_define("USE_NOISE_SPARE_EPHEMERAL") + + manifest.to_code = to_code_testing diff --git a/tests/components/noise/test_noise_handshake.cpp b/tests/components/noise/test_noise_handshake.cpp index f2081f2965..f9548a61f0 100644 --- a/tests/components/noise/test_noise_handshake.cpp +++ b/tests/components/noise/test_noise_handshake.cpp @@ -157,6 +157,72 @@ TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) { noise_cipherstate_free(recv_cipher); } +// One full NNpsk0 handshake; responder_e gets the ephemeral public key the responder put on the wire, read +// before the initiator consumes the buffer in place +static void run_handshake(NoiseResponderHandshake &responder, uint8_t responder_e[SPARE_EPHEMERAL_KEY_SIZE]) { + const psk_t psk = make_psk(7); + ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0); + Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE)); + uint8_t msg[MAX_HANDSHAKE_SIZE]; + size_t msg_len = initiator.write_message(msg, sizeof(msg)); + ASSERT_EQ(responder.read_message(msg, msg_len), 0); + size_t reply_len = 0; + ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0); + ASSERT_GE(reply_len, SPARE_EPHEMERAL_KEY_SIZE); + std::memcpy(responder_e, msg, SPARE_EPHEMERAL_KEY_SIZE); + ASSERT_EQ(initiator.read_message(msg, reply_len), 0); + ASSERT_EQ(responder.action(), Action::ACTION_SPLIT); +} + +TEST(SpareEphemeralTest, EmptySlotLeavesHandshakeToGenerate) { + ASSERT_FALSE(has_spare_ephemeral()); + NoiseResponderHandshake responder; + uint8_t responder_e[SPARE_EPHEMERAL_KEY_SIZE]; + run_handshake(responder, responder_e); + EXPECT_FALSE(has_spare_ephemeral()); +} + +TEST(SpareEphemeralTest, ConsumeHandsTheKeyToANewState) { + prepare_spare_ephemeral(); + ASSERT_TRUE(has_spare_ephemeral()); + 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, + }; + NoiseHandshakeState *state = nullptr; + ASSERT_EQ(noise_handshakestate_new_by_id(&state, &nid, NOISE_ROLE_RESPONDER), 0); + const psk_t psk = make_psk(7); + ASSERT_EQ(noise_handshakestate_set_pre_shared_key(state, psk.data(), psk.size()), 0); + ASSERT_EQ(noise_handshakestate_set_prologue(state, PROLOGUE, sizeof(PROLOGUE)), 0); + EXPECT_EQ(consume_spare_ephemeral(state), 0); + EXPECT_FALSE(has_spare_ephemeral()); + noise_handshakestate_free(state); +} + +TEST(SpareEphemeralTest, SlotKeyIsOnTheWireAndConsumedOnce) { + prepare_spare_ephemeral(); + ASSERT_TRUE(has_spare_ephemeral()); + uint8_t expected_pub[SPARE_EPHEMERAL_KEY_SIZE]; + std::memcpy(expected_pub, spare_ephemeral + SPARE_EPHEMERAL_KEY_SIZE, sizeof(expected_pub)); + + NoiseResponderHandshake first; + uint8_t responder_e[SPARE_EPHEMERAL_KEY_SIZE]; + run_handshake(first, responder_e); + // The spare, not a generated key, went out; and it went out once + EXPECT_EQ(std::memcmp(responder_e, expected_pub, sizeof(expected_pub)), 0); + EXPECT_FALSE(has_spare_ephemeral()); + + NoiseResponderHandshake second; + run_handshake(second, responder_e); + EXPECT_NE(std::memcmp(responder_e, expected_pub, sizeof(expected_pub)), 0); + EXPECT_FALSE(has_spare_ephemeral()); +} + 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