[noise] Generate the responder ephemeral key ahead of the handshake (#19000)

This commit is contained in:
J. Nick Koston
2026-10-01 16:15:31 +00:00
committed by GitHub
parent 56a7a11162
commit 826571518a
12 changed files with 174 additions and 4 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,
@@ -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")
+6
View File
@@ -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<ConnectionState>(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<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
this->is_authenticated();
+21 -2
View File
@@ -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];
+4 -1
View File
@@ -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
+5
View File
@@ -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")
+34
View File
@@ -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 <cstring>
#include <noise/protocol.h>
#include <sodium.h>
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");
+18
View File
@@ -6,6 +6,9 @@
#include <cstdint>
#include "esphome/core/log.h"
// Forward decl from <noise/protocol.h>
using NoiseHandshakeState = struct NoiseHandshakeState_s;
namespace esphome::noise {
using psk_t = std::array<uint8_t, 32>;
@@ -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
@@ -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
@@ -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
+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,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