From bf718a28b968cd616983a5ae2348253a9b34949d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Sep 2026 16:35:54 +0200 Subject: [PATCH] [noise] Assert the spare key reaches the wire, drop the ESP8266 warning threshold The gtest now checks that the responder's message carries the slot's public key and that the next handshake uses a different one, so a silently refused spare cannot pass. The api refill sites are guarded by the feature define they use. The ESP8266 blocking threshold change is left to a separate core change for operations that cannot be shortened. --- esphome/components/api/api_server.cpp | 9 +--- esphome/components/api/api_server.h | 4 +- .../components/noise/test_noise_handshake.cpp | 51 ++++++++++++++++--- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 1a7a86f7fe..67e03f4a8d 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -41,11 +41,6 @@ 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; @@ -150,7 +145,7 @@ void APIServer::loop() { // Checked once per pass for the refill and for the clients below const bool connected = network::is_connected(); -#ifdef USE_API_NOISE +#ifdef USE_NOISE_SPARE_EPHEMERAL // Only the flag test is inline; refilling is the rare path if (connected && !noise::has_spare_ephemeral()) { this->refill_spare_ephemeral_(); @@ -201,7 +196,7 @@ void APIServer::loop() { } } -#ifdef USE_API_NOISE +#ifdef USE_NOISE_SPARE_EPHEMERAL // Called with the network up; refill only while no api client is still // connecting (an OTA handshake is not visible here and just pays the refill // it triggered). diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 6127d0dec7..e9307d1304 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -363,8 +363,10 @@ class APIServer final : public Component, uint8_t provisioning_source_{0}; #endif -#ifdef USE_API_NOISE +#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 SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key diff --git a/tests/components/noise/test_noise_handshake.cpp b/tests/components/noise/test_noise_handshake.cpp index d7ad8cdba6..29ca34af08 100644 --- a/tests/components/noise/test_noise_handshake.cpp +++ b/tests/components/noise/test_noise_handshake.cpp @@ -157,8 +157,11 @@ 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) { +// Drive one full NNpsk0 handshake between a fresh initiator and responder; +// responder_e receives the ephemeral public key the responder put on the +// wire (the clear text start of its message, taken before the initiator +// consumes the buffer in place) +static void run_handshake(NoiseResponderHandshake &responder, uint8_t responder_e[32]) { const psk_t psk = make_psk(7); ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE)), 0); Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE)); @@ -167,25 +170,57 @@ static void run_handshake(NoiseResponderHandshake &responder) { 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, 32u); + std::memcpy(responder_e, msg, 32); ASSERT_EQ(initiator.read_message(msg, reply_len), 0); ASSERT_EQ(responder.action(), Action::ACTION_SPLIT); } TEST(SpareEphemeralTest, EmptySlotLeavesHandshakeToGenerate) { NoiseResponderHandshake responder; - run_handshake(responder); + uint8_t responder_e[32]; + run_handshake(responder, responder_e); EXPECT_FALSE(has_spare_ephemeral()); } -TEST(SpareEphemeralTest, SlotIsConsumedByExactlyOneHandshake) { +TEST(SpareEphemeralTest, ConsumeHandsTheKeyToANewState) { prepare_spare_ephemeral(); ASSERT_TRUE(has_spare_ephemeral()); - NoiseResponderHandshake first; - run_handshake(first); - // Consumed: the next handshake finds no spare and still completes + 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[32]; + std::memcpy(expected_pub, spare_ephemeral + 32, sizeof(expected_pub)); + + NoiseResponderHandshake first; + uint8_t responder_e[32]; + 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); + run_handshake(second, responder_e); + EXPECT_NE(std::memcmp(responder_e, expected_pub, sizeof(expected_pub)), 0); EXPECT_FALSE(has_spare_ephemeral()); }