diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 29b2858aee..d18a722c9e 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -10,6 +10,7 @@ #include "proto.h" #include #include +#include #ifdef USE_ESP8266 #include @@ -548,7 +549,10 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { * @return 0 on success, -1 on error (check errno) */ APIError APINoiseFrameHelper::init_handshake_() { - int err = this->handshake_.init(this->ctx_, prologue_.data(), prologue_.size()); + 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()); APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 78ebe5c38e..1d9eb297a9 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -138,6 +138,9 @@ void APIServer::setup() { } void APIServer::loop() { +#ifdef USE_API_NOISE + this->prepare_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 +191,28 @@ 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 an api client is still in its handshake +// (an OTA handshake is not visible here; it took the previous spare and +// pays the refill instead, no worse than generating its own key). The +// generation blocks the loop for about 60 ms on ESP8266, over the 50 ms +// blocking warning, so the api component's warning threshold ratchets up +// on the first refill; 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; + } + for (auto &client : this->active_clients()) { + if (!client->is_connection_setup()) { + return; + } + } + noise::prepare_spare_ephemeral(); +} +#endif + void APIServer::remove_client_(uint8_t client_index) { auto &client = this->clients_[client_index]; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 618ea4eb11..673def615a 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -364,6 +364,7 @@ class APIServer final : public Component, #endif #ifdef USE_API_NOISE + void prepare_spare_ephemeral_(); 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/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 7401413d6d..4b362a8890 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -8,6 +8,7 @@ #include #include +#include #ifdef USE_ESP8266 #include @@ -65,9 +66,21 @@ 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 + 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)); + 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 if (err != 0) { // Raw noise codes throughout: the name table would cost flash in builds // where only the OTA uses noise diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index 4806706167..f4743bbe03 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -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 #include #include +#include #ifdef USE_ESP8266 #include @@ -24,6 +26,38 @@ 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) +static bool spare_ephemeral_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool has_spare_ephemeral() { return spare_ephemeral_ready; } + +void prepare_spare_ephemeral() { + uint8_t *private_key = spare_ephemeral; + 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, EPHEMERAL_PRIVATE_KEY_SIZE)) { + return; + } + private_key[0] &= 0xF8; + 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; +} + +bool take_spare_ephemeral(ephemeral_keypair_t &out) { + if (!spare_ephemeral_ready) { + return false; + } + std::memcpy(out.data(), spare_ephemeral, EPHEMERAL_KEYPAIR_SIZE); + sodium_memzero(spare_ephemeral, EPHEMERAL_KEYPAIR_SIZE); + spare_ephemeral_ready = false; + return true; +} +#endif // USE_API_NOISE + const LogString *noise_err_to_logstr(int err) { if (err == NOISE_ERROR_NO_MEMORY) return LOG_STR("NO_MEMORY"); diff --git a/esphome/components/noise/noise.h b/esphome/components/noise/noise.h index 1033d5423c..ade22e22be 100644 --- a/esphome/components/noise/noise.h +++ b/esphome/components/noise/noise.h @@ -38,6 +38,27 @@ 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; +using ephemeral_keypair_t = std::array; + +#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. 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 and empty the slot. Returns false, +/// leaving out untouched, when the slot is empty. +bool take_spare_ephemeral(ephemeral_keypair_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. // Handshake payloads start with a status byte; transport payloads end with diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index cc7fa603c4..51d5315f8c 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -20,7 +20,8 @@ NoiseResponderHandshake::~NoiseResponderHandshake() { } } -int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len) { +int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len, + const uint8_t *ephemeral_keypair) { if (this->handshake_ != nullptr) { noise_handshakestate_free(this->handshake_); this->handshake_ = nullptr; @@ -57,6 +58,15 @@ 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 when the spare is refused + if (err != 0) { + HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", err); + } + } err = noise_handshakestate_start(this->handshake_); if (err != 0) { HANDSHAKE_STEP_LOG("noise_handshakestate_start", err); diff --git a/esphome/components/noise/noise_handshake.h b/esphome/components/noise/noise_handshake.h index bf1aa8cb7f..05f8bb94bb 100644 --- a/esphome/components/noise/noise_handshake.h +++ b/esphome/components/noise/noise_handshake.h @@ -38,7 +38,12 @@ class NoiseResponderHandshake { /// Create and start the handshake with the context's PSK and the prologue. /// A repeated call frees the previous handshake state and starts over. - [[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len); + /// 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; if noise-c refuses it the handshake + /// generates its own key and init() still succeeds. + [[nodiscard]] int init(const NoiseContext &ctx, 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() /// has released the state, and when noise-c reports a failed handshake. [[nodiscard]] Action action() const; 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 f2081f2965..14556bbf11 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" @@ -157,6 +158,61 @@ 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)); + + const psk_t psk = make_psk(7); + NoiseResponderHandshake responder; + ASSERT_EQ(responder.init(ctx_for(psk), PROLOGUE, sizeof(PROLOGUE), spare.data()), 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.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); +} + 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