From 67665a7f3a008e58b1cee8e198453bc2ff328c36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Sep 2026 18:34:29 +0200 Subject: [PATCH] [noise] Generate the responder ephemeral key ahead of the handshake The responder's ephemeral key pair was generated inside the handshake write step, a base point multiply of about 60 ms on ESP8266 that every connecting client waited for. The noise component now keeps one spare key pair (64 bytes of static storage), the api server refills it from loop() once the network is up and no client is mid handshake, and both the api and ota handshakes take it through noise-c's new noise_handshakestate_set_local_ephemeral(). A handshake that finds the slot empty generates its own key as before. --- .../components/api/api_frame_helper_noise.cpp | 6 +++- esphome/components/api/api_server.cpp | 20 ++++++++++++ esphome/components/api/api_server.h | 1 + .../esphome/ota/ota_esphome_noise.cpp | 6 +++- esphome/components/noise/noise.cpp | 32 +++++++++++++++++++ esphome/components/noise/noise.h | 13 ++++++++ esphome/components/noise/noise_handshake.cpp | 10 +++++- esphome/components/noise/noise_handshake.h | 6 +++- 8 files changed, 90 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 138dbdddba..cb3fafe15a 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -10,6 +10,7 @@ #include "proto.h" #include #include +#include #ifdef USE_ESP8266 #include @@ -548,7 +549,10 @@ 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()); + uint8_t spare[noise::EPHEMERAL_KEYPAIR_SIZE]; + const uint8_t *ephemeral = noise::take_spare_ephemeral(spare) ? spare : nullptr; + int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size(), ephemeral); + sodium_memzero(spare, sizeof(spare)); 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..72a8cad238 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -138,6 +138,9 @@ void APIServer::setup() { } void APIServer::loop() { +#ifdef USE_API_NOISE + this->prepare_spare_ephemeral_(); +#endif // Accept new clients only if the socket exists and has incoming connections if (this->socket_ && this->socket_->ready()) { this->accept_new_connections_(); @@ -188,6 +191,23 @@ void APIServer::loop() { } } +#ifdef USE_API_NOISE +// Refill the spare ephemeral key while nobody is waiting for it: not before +// the network is up, and not while a client is still in its handshake, since +// the key generation blocks the loop for its duration. +void APIServer::prepare_spare_ephemeral_() { + if (noise::has_spare_ephemeral() || !network::is_connected()) { + return; + } + for (auto &client : this->active_clients()) { + if (!client->is_connection_setup()) { + 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 072a583901..1dfe9f25f3 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -357,6 +357,7 @@ class APIServer final : public Component, #endif #ifdef USE_API_NOISE + void prepare_spare_ephemeral_(); noise::NoiseContext noise_ctx_; ESPPreferenceObject noise_pref_; #endif // USE_API_NOISE diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 7f8331cf96..738a87242b 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -7,6 +7,7 @@ #include #include +#include #ifdef USE_ESP8266 #include @@ -71,7 +72,10 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { *p++ = ota::OTA_RESPONSE_FEATURE_FLAGS; *p++ = server_feature_flags; - int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue)); + uint8_t spare[noise::EPHEMERAL_KEYPAIR_SIZE]; + const uint8_t *ephemeral = noise::take_spare_ephemeral(spare) ? spare : nullptr; + int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue), ephemeral); + sodium_memzero(spare, sizeof(spare)); if (err != 0) { ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); this->cleanup_connection_(); diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index 95fab322db..a27ba3938b 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -1,11 +1,13 @@ #include "noise.h" #ifdef USE_NOISE +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include #include +#include #ifdef USE_ESP8266 #include @@ -15,6 +17,36 @@ namespace esphome::noise { static const char *const TAG = "noise"; +static uint8_t spare_ephemeral[EPHEMERAL_KEYPAIR_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool spare_ephemeral_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool has_spare_ephemeral() { return spare_ephemeral_ready; } + +void prepare_spare_ephemeral() { + uint8_t *private_key = spare_ephemeral; + uint8_t *public_key = spare_ephemeral + 32; + // Same generation as noise-c's curve25519 backend: random bytes, X25519 + // clamping, then the public key. A random source failure leaves the slot + // empty; the handshake then generates its own key. + if (!random_bytes(private_key, 32)) { + return; + } + private_key[0] &= 0xF8; + private_key[31] = (private_key[31] & 0x7F) | 0x40; + crypto_scalarmult_curve25519_base(public_key, private_key); + spare_ephemeral_ready = true; +} + +bool take_spare_ephemeral(uint8_t *out) { + if (!spare_ephemeral_ready) { + return false; + } + std::memcpy(out, spare_ephemeral, EPHEMERAL_KEYPAIR_SIZE); + sodium_memzero(spare_ephemeral, EPHEMERAL_KEYPAIR_SIZE); + spare_ephemeral_ready = false; + return true; +} + 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 f9da8d35b8..fcf59a2c6d 100644 --- a/esphome/components/noise/noise.h +++ b/esphome/components/noise/noise.h @@ -38,6 +38,19 @@ class NoiseContext { /// Convert a noise error code to a readable error const LogString *noise_err_to_logstr(int err); +// A responder ephemeral key pair generated ahead of time. The base point +// multiply behind one costs about 60 ms on ESP8266, so the api server fills +// the slot while idle and a connecting client does not wait for it. One slot +// serves every noise transport; a handshake that finds it empty generates +// its own key as before. +static constexpr size_t EPHEMERAL_KEYPAIR_SIZE = 64; // 32 byte private key then 32 byte public key +bool has_spare_ephemeral(); +/// Generate a key pair into the slot; blocks for the base point multiply. +void prepare_spare_ephemeral(); +/// Move the slot's key pair into out (EPHEMERAL_KEYPAIR_SIZE bytes) and empty +/// the slot. Returns false, leaving out untouched, when the slot is empty. +bool take_spare_ephemeral(uint8_t *out); + // 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 6d426de012..712d6d8807 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -20,7 +20,8 @@ NoiseResponderHandshake::~NoiseResponderHandshake() { } } -int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) { +int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len, + const uint8_t *ephemeral_keypair) { if (this->handshake_ != nullptr) { noise_handshakestate_free(this->handshake_); this->handshake_ = nullptr; @@ -54,6 +55,13 @@ int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, siz HANDSHAKE_STEP_LOG("noise_handshakestate_set_prologue", err); return this->fail_init_(err); } + if (ephemeral_keypair != nullptr) { + err = noise_handshakestate_set_local_ephemeral(this->handshake_, ephemeral_keypair, 32, ephemeral_keypair + 32, 32); + if (err != 0) { + HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", err); + return this->fail_init_(err); + } + } 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 30596f35c2..7bd0fc7c95 100644 --- a/esphome/components/noise/noise_handshake.h +++ b/esphome/components/noise/noise_handshake.h @@ -38,7 +38,11 @@ class NoiseResponderHandshake { /// 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); + /// ephemeral_keypair, when not null, is a key pair from + /// take_spare_ephemeral() that the handshake uses as its ephemeral key + /// instead of generating one. + [[nodiscard]] int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len, + const uint8_t *ephemeral_keypair = nullptr); /// 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;