[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.
This commit is contained in:
J. Nick Koston
2026-09-06 22:28:51 +02:00
parent 67665a7f3a
commit 40b374c944
8 changed files with 97 additions and 10 deletions
+3 -1
View File
@@ -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;
@@ -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_();
+5 -3
View File
@@ -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)
+9 -2
View File
@@ -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.
+4 -2
View File
@@ -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_);
+2 -1
View File
@@ -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()
+9
View File
@@ -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
@@ -3,6 +3,7 @@
#include <cstring>
#include <noise/protocol.h>
#include <sodium.h>
#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<size_t>(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