Compare commits

...
Author SHA1 Message Date
J. Nick Koston edec6aaf5e [noise] Declare the noise-c state alias with using 2026-09-07 13:21:19 +02:00
J. Nick Koston 55804e6e16 [noise] Use the clamp bit of the spare key as its ready flag
A clamped X25519 private key always has bit 254 set, so byte 31 of the
slot says whether a key is present and a wiped slot reads empty; the
separate flag and its padding go, leaving the slot at exactly 64 bytes.
2026-09-07 12:45:25 +02:00
J. Nick Koston 228f8894a9 [noise] Consume the spare key inside the handshake and let noise own its define
NoiseResponderHandshake::init() now hands the slot straight to noise-c and
wipes it, so the api and ota call sites are unchanged, nothing copies the
key pair and no transport has to remember the wipe. The slot compiles
under USE_NOISE_SPARE_EPHEMERAL, which the api component enables as the
refiller, instead of the noise component keying on an api define. The
per tick check sits in loop() with the refill out of line, and the grace
predicate lives next to the handshake timeout it mirrors.
2026-09-07 12:33:50 +02:00
J. Nick Koston 866574ca14 [noise] Keep only the slot flag test inline in the api loop
The per tick check is a byte load and branch now; the network check,
client scan and refill live in prepare_spare_ephemeral_slow_(), called
only while the slot is empty.
2026-09-07 12:23:09 +02:00
J. Nick Koston 5747c736c2 [noise] Inline the spare slot check, keep the slot empty if the base multiply fails
has_spare_ephemeral() is polled every api loop tick, so the flag is now
an extern and the accessor lives in the header.
2026-09-07 12:21:04 +02:00
J. Nick Koston 0f6c266cd7 [noise] Give the refill pass 100 ms before the blocking warning on ESP8266 2026-09-07 00:58:02 +02:00
J. Nick Koston 05dbc5ee59 [noise] Hold the refill only for connections still inside their grace period
Gating on the noise handshake alone let the refill land between the
handshake and the hello response, inside the window being optimized; a
connection now holds the slot while it is unauthenticated and younger
than a second, so a fresh client gets through its hello first and a stale
half open one stops holding the slot after that.
2026-09-07 00:47:55 +02:00
J. Nick Koston 29f7439154 [noise] Shorten the comments 2026-09-07 00:39:22 +02:00
J. Nick Koston 89cd183a9f [noise] Gate the refill on the noise handshake, cover its loop time on ESP8266
The refill now waits only for api clients still in their noise handshake,
not for any client that has yet to send its hello, so a stale half open
connection cannot keep the slot empty for a minute. On ESP8266 the api
server raises its blocking warning threshold to 80 ms in setup(), since
the refill takes about 60 ms there and used to run inside every handshake
anyway; and prepare_spare_ephemeral() clears the ready flag before
filling, so a random source failure can never leave a mismatched pair.
2026-09-07 00:34:20 +02:00
J. Nick Koston 370cfb8898 [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, only in builds with an encrypted api
since the api server is the only refiller), the api server refills it from
loop() once the network is up and no api client is mid handshake, and both
the api and ota handshakes take it through noise-c's
noise_handshakestate_set_local_ephemeral(). A handshake that finds the
slot empty, or whose spare noise-c refuses, generates its own key as
before. The host gtest suite covers the slot's single use, the key pair's
consistency, and a full handshake whose message carries the supplied key.
2026-09-07 00:17:20 +02:00
13 changed files with 152 additions and 1 deletions
+2
View File
@@ -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,
@@ -603,6 +604,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")
@@ -77,6 +77,8 @@ static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS *
// WiFi (-70 dBm+), TCP retransmissions push real-world handshake times to
// 28-30s. See https://github.com/esphome/esphome/issues/14999
static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 60000;
// How long a new connection holds off the spare ephemeral refill
static constexpr uint32_t CONNECT_GRACE_MS = 1000;
static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION);
@@ -250,6 +252,10 @@ void APIConnection::begin_iterator_(ActiveIterator type) {
}
}
bool APIConnection::is_still_connecting(uint32_t now) {
return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS;
}
void APIConnection::loop() {
if (this->flags_.next_close) {
// requested a disconnect - don't close socket here, let APIServer::loop() do it
+3
View File
@@ -319,6 +319,9 @@ class APIConnection final : public APIServerConnectionBase {
bool is_authenticated() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
}
// Unauthenticated and within its grace period; an older unauthenticated
// connection is a stale half open client and no longer counts
bool is_still_connecting(uint32_t now);
bool is_connection_setup() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
this->is_authenticated();
+28
View File
@@ -41,6 +41,11 @@ void APIServer::setup() {
ControllerRegistry::register_controller(this);
#ifdef USE_API_NOISE
#ifdef USE_ESP8266
// The spare ephemeral refill blocks ~60 ms here and shares the pass with
// the client loops; keep the whole pass under the blocking warning
this->warn_if_blocking_over_ = 10; // centiseconds
#endif
// Always reserve the slot: flash preferences are positional on esp8266, so
// a yaml key build must keep the layout of a runtime key build
uint32_t hash = 88491486UL;
@@ -138,6 +143,12 @@ void APIServer::setup() {
}
void APIServer::loop() {
#ifdef USE_API_NOISE
// Only the flag test is inline; refilling is the rare path
if (!noise::has_spare_ephemeral()) {
this->refill_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 +199,23 @@ void APIServer::loop() {
}
}
#ifdef USE_API_NOISE
// Refill only while no api client is still connecting; an OTA handshake is
// not visible here and just pays the refill it triggered.
void APIServer::refill_spare_ephemeral_() {
if (!network::is_connected()) {
return;
}
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];
+1
View File
@@ -364,6 +364,7 @@ class APIServer final : public Component,
#endif
#ifdef USE_API_NOISE
void refill_spare_ephemeral_();
noise::NoiseContext noise_ctx_;
#ifndef USE_API_NOISE_PSK_FROM_YAML
SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key
+5
View File
@@ -86,6 +86,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.24")
+36
View File
@@ -1,12 +1,14 @@
#include "noise.h"
#ifdef USE_NOISE
#include "esphome/core/hal.h"
#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>
@@ -24,6 +26,40 @@ 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 = 32;
static constexpr size_t PUBLIC_KEY_SIZE = 32;
static_assert(PRIVATE_KEY_SIZE + PUBLIC_KEY_SIZE == SPARE_EPHEMERAL_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 curve25519 keygen; the clamp sets the ready bit,
// a failure wipes the slot so the handshake generates its own key
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");
+21
View File
@@ -6,6 +6,9 @@
#include <cstdint>
#include "esphome/core/log.h"
// noise-c handshake state; the full definition lives in <noise/protocol.h>
using NoiseHandshakeState = struct NoiseHandshakeState_s;
namespace esphome::noise {
using psk_t = std::array<uint8_t, 32>;
@@ -38,6 +41,24 @@ 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 generated ahead of time (about 60 ms on
// ESP8266), refilled by the api server while idle and consumed by the next
// handshake of any noise transport; an empty slot means the handshake
// generates its own key.
// Private key then public key; zero when empty
static constexpr size_t SPARE_EPHEMERAL_SIZE = 64;
extern uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// Polled every api loop tick, so it must inline. A clamped X25519 private key
// always has bit 254 set, so that byte doubles as the ready flag.
inline bool has_spare_ephemeral() { return (spare_ephemeral[31] & 0x40) != 0; }
/// Fill the slot; blocks for the base point multiply
void prepare_spare_ephemeral();
/// Hand the slot's key pair to a handshake that has not started and wipe the
/// slot; 0 when the slot was empty or the key was taken, else the noise-c error
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
@@ -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);
+2 -1
View File
@@ -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.
+1
View File
@@ -230,6 +230,7 @@
#define USE_IMPROV_SERIAL_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
+8
View File
@@ -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
@@ -157,6 +157,38 @@ TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
noise_cipherstate_free(recv_cipher);
}
// Drive one full NNpsk0 handshake between a fresh initiator and responder
static void run_handshake(NoiseResponderHandshake &responder) {
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_EQ(initiator.read_message(msg, reply_len), 0);
ASSERT_EQ(responder.action(), Action::ACTION_SPLIT);
}
TEST(SpareEphemeralTest, EmptySlotLeavesHandshakeToGenerate) {
NoiseResponderHandshake responder;
run_handshake(responder);
EXPECT_FALSE(has_spare_ephemeral());
}
TEST(SpareEphemeralTest, SlotIsConsumedByExactlyOneHandshake) {
prepare_spare_ephemeral();
ASSERT_TRUE(has_spare_ephemeral());
NoiseResponderHandshake first;
run_handshake(first);
// Consumed: the next handshake finds no spare and still completes
EXPECT_FALSE(has_spare_ephemeral());
NoiseResponderHandshake second;
run_handshake(second);
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