mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
[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:
@@ -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_();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user