From 40b374c9448532b7dde32a9012684f8bbf1ac458 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Sep 2026 22:28:51 +0200 Subject: [PATCH] [noise] Keep the spare slot to encrypted api builds, fall back when refused, add tests The slot only exists when the api server refills it, so an OTA-only encrypted build carries no dead storage; a refused spare no longer fails the handshake, the handshake generates its own key instead; the key sizes are named constants; the host gtest suite covers the slot's single use, the key pair's consistency, and a full handshake whose message carries the supplied public key. --- esphome/components/api/api_server.cpp | 4 +- .../esphome/ota/ota_esphome_noise.cpp | 10 +++- esphome/components/noise/noise.cpp | 8 ++- esphome/components/noise/noise.h | 11 +++- esphome/components/noise/noise_handshake.cpp | 6 +- esphome/components/noise/noise_handshake.h | 3 +- tests/components/noise/__init__.py | 9 +++ .../components/noise/test_noise_handshake.cpp | 56 +++++++++++++++++++ 8 files changed, 97 insertions(+), 10 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 72a8cad238..1f6aa9d763 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -194,7 +194,9 @@ 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. +// the key generation blocks the loop for its duration. On ESP8266 that is +// about 60 ms, over the 50 ms blocking warning; the same generation used to +// run inside the handshake, where it tripped the warning for longer. void APIServer::prepare_spare_ephemeral_() { if (noise::has_spare_ephemeral() || !network::is_connected()) { return; diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 738a87242b..f937ea8299 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -72,10 +72,18 @@ bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { *p++ = ota::OTA_RESPONSE_FEATURE_FLAGS; *p++ = server_feature_flags; + // The api server keeps the spare; without an encrypted api there is none + const uint8_t *ephemeral = nullptr; +#ifdef USE_API_NOISE uint8_t spare[noise::EPHEMERAL_KEYPAIR_SIZE]; - const uint8_t *ephemeral = noise::take_spare_ephemeral(spare) ? spare : nullptr; + if (noise::take_spare_ephemeral(spare)) { + ephemeral = spare; + } +#endif int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue), ephemeral); +#ifdef USE_API_NOISE sodium_memzero(spare, sizeof(spare)); +#endif if (err != 0) { ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err))); this->cleanup_connection_(); diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index a27ba3938b..a38b6cf368 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -17,6 +17,7 @@ namespace esphome::noise { static const char *const TAG = "noise"; +#ifdef USE_API_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) @@ -24,15 +25,15 @@ 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; + uint8_t *public_key = spare_ephemeral + EPHEMERAL_PRIVATE_KEY_SIZE; // 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)) { + if (!random_bytes(private_key, EPHEMERAL_PRIVATE_KEY_SIZE)) { return; } private_key[0] &= 0xF8; - private_key[31] = (private_key[31] & 0x7F) | 0x40; + private_key[EPHEMERAL_PRIVATE_KEY_SIZE - 1] = (private_key[EPHEMERAL_PRIVATE_KEY_SIZE - 1] & 0x7F) | 0x40; crypto_scalarmult_curve25519_base(public_key, private_key); spare_ephemeral_ready = true; } @@ -46,6 +47,7 @@ bool take_spare_ephemeral(uint8_t *out) { spare_ephemeral_ready = false; return true; } +#endif // USE_API_NOISE const LogString *noise_err_to_logstr(int err) { if (err == NOISE_ERROR_NO_MEMORY) diff --git a/esphome/components/noise/noise.h b/esphome/components/noise/noise.h index fcf59a2c6d..18a0da8d8f 100644 --- a/esphome/components/noise/noise.h +++ b/esphome/components/noise/noise.h @@ -38,18 +38,25 @@ class NoiseContext { /// Convert a noise error code to a readable error const LogString *noise_err_to_logstr(int err); +// An X25519 key pair as the spare ephemeral hands it out: private key first +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; + +#ifdef USE_API_NOISE // 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 +// its own key as before. The api server is the only refiller, so the slot +// only exists in builds with an encrypted api. 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); +#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. diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index 712d6d8807..b37e3a82fb 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -56,10 +56,12 @@ int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, siz return this->fail_init_(err); } if (ephemeral_keypair != nullptr) { - err = noise_handshakestate_set_local_ephemeral(this->handshake_, ephemeral_keypair, 32, ephemeral_keypair + 32, 32); + 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 when the spare is refused if (err != 0) { HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", err); - return this->fail_init_(err); } } err = noise_handshakestate_start(this->handshake_); diff --git a/esphome/components/noise/noise_handshake.h b/esphome/components/noise/noise_handshake.h index 7bd0fc7c95..f015446186 100644 --- a/esphome/components/noise/noise_handshake.h +++ b/esphome/components/noise/noise_handshake.h @@ -40,7 +40,8 @@ class NoiseResponderHandshake { /// repeated call frees the previous handshake state and starts over. /// 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. + /// instead of generating one; if noise-c refuses it the handshake + /// generates its own key and init() still succeeds. [[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() diff --git a/tests/components/noise/__init__.py b/tests/components/noise/__init__.py index 60a5740a83..d21f123031 100644 --- a/tests/components/noise/__init__.py +++ b/tests/components/noise/__init__.py @@ -1,3 +1,4 @@ +import esphome.codegen as cg from tests.testing_helpers import ComponentManifestOverride @@ -5,3 +6,11 @@ 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) + # The spare ephemeral slot only exists in builds with an encrypted api + cg.add_define("USE_API_NOISE") + + manifest.to_code = to_code_testing diff --git a/tests/components/noise/test_noise_handshake.cpp b/tests/components/noise/test_noise_handshake.cpp index d879a26c43..822a1eabaa 100644 --- a/tests/components/noise/test_noise_handshake.cpp +++ b/tests/components/noise/test_noise_handshake.cpp @@ -3,6 +3,7 @@ #include #include +#include #include "esphome/components/noise/noise.h" #include "esphome/components/noise/noise_handshake.h" @@ -149,6 +150,61 @@ TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) { noise_cipherstate_free(recv_cipher); } +TEST(SpareEphemeralTest, EmptySlotHandsOutNothing) { + uint8_t out[EPHEMERAL_KEYPAIR_SIZE]; + // 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) { + uint8_t out[EPHEMERAL_KEYPAIR_SIZE]; + 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), 0); + EXPECT_EQ(std::memcmp(check, out + EPHEMERAL_PRIVATE_KEY_SIZE, EPHEMERAL_PUBLIC_KEY_SIZE), 0); +} + +TEST(SpareEphemeralTest, SuppliedKeyPairCompletesHandshakeAndIsTheKeyOnTheWire) { + uint8_t spare[EPHEMERAL_KEYPAIR_SIZE]; + take_spare_ephemeral(spare); + prepare_spare_ephemeral(); + ASSERT_TRUE(take_spare_ephemeral(spare)); + + const psk_t psk = make_psk(7); + NoiseResponderHandshake responder; + ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE), spare), 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(EPHEMERAL_PUBLIC_KEY_SIZE)); + EXPECT_EQ(std::memcmp(msg, spare + 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); +} + 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