[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.
This commit is contained in:
J. Nick Koston
2026-09-06 22:15:56 +02:00
parent 7c208b4815
commit 67665a7f3a
8 changed files with 90 additions and 4 deletions
@@ -10,6 +10,7 @@
#include "proto.h"
#include <cstring>
#include <cinttypes>
#include <sodium.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -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;
+20
View File
@@ -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];
+1
View File
@@ -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
@@ -7,6 +7,7 @@
#include <cstring>
#include <new>
#include <sodium.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -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_();
+32
View File
@@ -1,11 +1,13 @@
#include "noise.h"
#ifdef USE_NOISE
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
#include <noise/protocol.h>
#include <sodium.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -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");
+13
View File
@@ -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
+9 -1
View File
@@ -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);
+5 -1
View File
@@ -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;