[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.
This commit is contained in:
J. Nick Koston
2026-09-07 12:33:50 +02:00
parent 866574ca14
commit 228f8894a9
15 changed files with 89 additions and 123 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 -6
View File
@@ -316,15 +316,12 @@ class APIConnection final : public APIServerConnectionBase {
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg);
#endif
static constexpr uint32_t CONNECT_GRACE_MS = 1000;
bool is_authenticated() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
}
// A connection that is still setting up within its grace period; an older
// unauthenticated one is a stale half open client and no longer counts
bool is_still_connecting(uint32_t now) {
return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS;
}
// 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();
@@ -10,7 +10,6 @@
#include "proto.h"
#include <cstring>
#include <cinttypes>
#include <sodium.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -549,10 +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_() {
noise::ephemeral_keypair_t spare;
const uint8_t *ephemeral = noise::take_spare_ephemeral(spare) ? spare.data() : nullptr;
int err = this->handshake_.init(this->ctx_, prologue_.data(), prologue_.size(), ephemeral);
sodium_memzero(spare.data(), spare.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;
+5 -2
View File
@@ -144,7 +144,10 @@ void APIServer::setup() {
void APIServer::loop() {
#ifdef USE_API_NOISE
this->prepare_spare_ephemeral_();
// 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()) {
@@ -199,7 +202,7 @@ 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::prepare_spare_ephemeral_slow_() {
void APIServer::refill_spare_ephemeral_() {
if (!network::is_connected()) {
return;
}
+1 -7
View File
@@ -364,13 +364,7 @@ class APIServer final : public Component,
#endif
#ifdef USE_API_NOISE
// Polled every loop tick: only the flag test stays inline
void prepare_spare_ephemeral_() {
if (!noise::has_spare_ephemeral()) {
this->prepare_spare_ephemeral_slow_();
}
}
void prepare_spare_ephemeral_slow_();
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
@@ -8,7 +8,6 @@
#include <cstring>
#include <new>
#include <sodium.h>
#ifdef USE_ESP8266
#include <pgmspace.h>
@@ -66,21 +65,9 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) {
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
*p++ = server_feature_flags;
// Only the api server refills the spare
const uint8_t *ephemeral = nullptr;
#ifdef USE_API_NOISE
noise::ephemeral_keypair_t spare;
if (this->noise_ != nullptr && noise::take_spare_ephemeral(spare)) {
ephemeral = spare.data();
}
#endif
// The caller only starts a session when the context holds a key
int err = this->noise_ == nullptr
? NOISE_ERROR_NO_MEMORY
: this->noise_->handshake.init(this->noise_context_(), prologue, sizeof(prologue), ephemeral);
#ifdef USE_API_NOISE
sodium_memzero(spare.data(), spare.size());
#endif
int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY
: this->noise_->handshake.init(this->noise_context_(), prologue, sizeof(prologue));
if (err != 0) {
// Raw noise codes throughout: the name table would cost flash in builds
// where only the OTA uses noise
+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")
+20 -14
View File
@@ -26,39 +26,45 @@ void NoiseContext::load_psk(psk_t &out) const {
progmem_memcpy(out.data(), this->psk_, out.size());
}
#ifdef USE_API_NOISE
static uint8_t spare_ephemeral[EPHEMERAL_KEYPAIR_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool spare_ephemeral_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#ifdef USE_NOISE_SPARE_EPHEMERAL
static constexpr size_t PRIVATE_KEY_SIZE = 32;
static constexpr size_t PUBLIC_KEY_SIZE = 32;
// Private key then public key
static uint8_t
spare_ephemeral[PRIVATE_KEY_SIZE + PUBLIC_KEY_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool spare_ephemeral_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void prepare_spare_ephemeral() {
// A partial fill must never look ready
spare_ephemeral_ready = false;
uint8_t *private_key = spare_ephemeral;
uint8_t *public_key = spare_ephemeral + EPHEMERAL_PRIVATE_KEY_SIZE;
// Same steps as noise-c's curve25519 keygen; on RNG failure the slot stays
uint8_t *public_key = spare_ephemeral + PRIVATE_KEY_SIZE;
// Same steps as noise-c's curve25519 keygen; on failure the slot stays
// empty and the handshake generates its own key
if (!random_bytes(private_key, EPHEMERAL_PRIVATE_KEY_SIZE)) {
if (!random_bytes(private_key, PRIVATE_KEY_SIZE)) {
return;
}
private_key[0] &= 0xF8;
private_key[EPHEMERAL_PRIVATE_KEY_SIZE - 1] = (private_key[EPHEMERAL_PRIVATE_KEY_SIZE - 1] & 0x7F) | 0x40;
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, EPHEMERAL_KEYPAIR_SIZE);
sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral));
return;
}
spare_ephemeral_ready = true;
}
bool take_spare_ephemeral(ephemeral_keypair_t &out) {
int consume_spare_ephemeral(NoiseHandshakeState *state) {
if (!spare_ephemeral_ready) {
return false;
return 0;
}
std::memcpy(out.data(), spare_ephemeral, EPHEMERAL_KEYPAIR_SIZE);
sodium_memzero(spare_ephemeral, EPHEMERAL_KEYPAIR_SIZE);
// 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));
spare_ephemeral_ready = false;
return true;
return err;
}
#endif // USE_API_NOISE
#endif // USE_NOISE_SPARE_EPHEMERAL
const LogString *noise_err_to_logstr(int err) {
if (err == NOISE_ERROR_NO_MEMORY)
+10 -11
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>
typedef struct NoiseHandshakeState_s NoiseHandshakeState;
namespace esphome::noise {
using psk_t = std::array<uint8_t, 32>;
@@ -38,23 +41,19 @@ class NoiseContext {
/// Convert a noise error code to a readable error
const LogString *noise_err_to_logstr(int err);
// Spare key pair layout: private key then public key
static constexpr size_t EPHEMERAL_PRIVATE_KEY_SIZE = 32;
static constexpr size_t EPHEMERAL_PUBLIC_KEY_SIZE = 32;
static constexpr size_t EPHEMERAL_KEYPAIR_SIZE = EPHEMERAL_PRIVATE_KEY_SIZE + EPHEMERAL_PUBLIC_KEY_SIZE;
using ephemeral_keypair_t = std::array<uint8_t, EPHEMERAL_KEYPAIR_SIZE>;
#ifdef USE_API_NOISE
#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, shared by every noise
// transport; an empty slot means the handshake generates its own key.
// 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.
extern bool spare_ephemeral_ready; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// Polled every api loop tick, so it must inline
inline bool has_spare_ephemeral() { return spare_ephemeral_ready; }
/// Fill the slot; blocks for the base point multiply
void prepare_spare_ephemeral();
/// Move the slot into out and empty it; false (out untouched) when empty
bool take_spare_ephemeral(ephemeral_keypair_t &out);
/// 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
+7 -10
View File
@@ -20,8 +20,7 @@ NoiseResponderHandshake::~NoiseResponderHandshake() {
}
}
int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len,
const uint8_t *ephemeral_keypair) {
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;
@@ -58,15 +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);
}
if (ephemeral_keypair != nullptr) {
err = noise_handshakestate_set_local_ephemeral(this->handshake_, ephemeral_keypair, EPHEMERAL_PRIVATE_KEY_SIZE,
ephemeral_keypair + EPHEMERAL_PRIVATE_KEY_SIZE,
EPHEMERAL_PUBLIC_KEY_SIZE);
// Not fatal: the handshake generates its own key instead
if (err != 0) {
HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", 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);
+3 -5
View File
@@ -37,11 +37,9 @@ 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.
/// ephemeral_keypair, when set, is a take_spare_ephemeral() key pair used
/// instead of generating one; a refused pair falls back to generating.
[[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len,
const uint8_t *ephemeral_keypair = nullptr);
/// 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.
[[nodiscard]] Action action() const;
+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
+1 -2
View File
@@ -10,7 +10,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config):
await real_to_code(config)
# The spare ephemeral slot only exists in builds with an encrypted api
cg.add_define("USE_API_NOISE")
cg.add_define("USE_NOISE_SPARE_EPHEMERAL")
manifest.to_code = to_code_testing
+22 -46
View File
@@ -3,7 +3,6 @@
#include <cstring>
#include <noise/protocol.h>
#include <sodium.h>
#include "esphome/components/noise/noise.h"
#include "esphome/components/noise/noise_handshake.h"
@@ -158,59 +157,36 @@ TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
noise_cipherstate_free(recv_cipher);
}
TEST(SpareEphemeralTest, EmptySlotHandsOutNothing) {
ephemeral_keypair_t out;
// Drain whatever an earlier test left behind, then the slot must stay empty
take_spare_ephemeral(out);
EXPECT_FALSE(has_spare_ephemeral());
EXPECT_FALSE(take_spare_ephemeral(out));
}
TEST(SpareEphemeralTest, KeyPairIsHandedOutExactlyOnce) {
ephemeral_keypair_t out;
take_spare_ephemeral(out);
prepare_spare_ephemeral();
ASSERT_TRUE(has_spare_ephemeral());
ASSERT_TRUE(take_spare_ephemeral(out));
// Taken once: the slot is empty and a second take gets nothing
EXPECT_FALSE(has_spare_ephemeral());
EXPECT_FALSE(take_spare_ephemeral(out));
// The pair is consistent: the public half is the base point multiple of the private half
uint8_t check[EPHEMERAL_PUBLIC_KEY_SIZE];
ASSERT_EQ(crypto_scalarmult_curve25519_base(check, out.data()), 0);
EXPECT_EQ(std::memcmp(check, out.data() + EPHEMERAL_PRIVATE_KEY_SIZE, EPHEMERAL_PUBLIC_KEY_SIZE), 0);
}
TEST(SpareEphemeralTest, SuppliedKeyPairCompletesHandshakeAndIsTheKeyOnTheWire) {
ephemeral_keypair_t spare;
take_spare_ephemeral(spare);
prepare_spare_ephemeral();
ASSERT_TRUE(take_spare_ephemeral(spare));
// Drive one full NNpsk0 handshake between a fresh initiator and responder
static void run_handshake(NoiseResponderHandshake &responder) {
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE), spare.data()), 0);
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);
// The responder's message starts with its ephemeral public key
ASSERT_GE(reply_len, static_cast<size_t>(EPHEMERAL_PUBLIC_KEY_SIZE));
EXPECT_EQ(std::memcmp(msg, spare.data() + EPHEMERAL_PRIVATE_KEY_SIZE, EPHEMERAL_PUBLIC_KEY_SIZE), 0);
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
initiator.split();
NoiseCipherState *send_cipher = nullptr;
NoiseCipherState *recv_cipher = nullptr;
ASSERT_EQ(responder.split(send_cipher, recv_cipher), 0);
ASSERT_NE(send_cipher, nullptr);
noise_cipherstate_free(send_cipher);
noise_cipherstate_free(recv_cipher);
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) {