Add host unit tests for the noise component

This commit is contained in:
J. Nick Koston
2026-08-20 01:16:40 -05:00
parent c6bb7e48d4
commit b0be797dfb
3 changed files with 272 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
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()
@@ -0,0 +1,191 @@
#include <gtest/gtest.h>
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
#include "esphome/components/noise/noise_handshake.h"
namespace esphome::noise::testing {
using Action = NoiseResponderHandshake::Action;
// A raw noise-c initiator driving the same Noise_NNpsk0_25519_ChaChaPoly_SHA256
// pattern the responder class implements, so the tests exercise a real
// two-message handshake rather than mirrored calls into the class under test.
class Initiator {
public:
Initiator(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
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,
};
EXPECT_EQ(noise_handshakestate_new_by_id(&this->state_, &nid, NOISE_ROLE_INITIATOR), 0);
EXPECT_EQ(noise_handshakestate_set_pre_shared_key(this->state_, psk.data(), psk.size()), 0);
EXPECT_EQ(noise_handshakestate_set_prologue(this->state_, prologue, prologue_len), 0);
EXPECT_EQ(noise_handshakestate_start(this->state_), 0);
}
~Initiator() {
if (this->state_ != nullptr)
noise_handshakestate_free(this->state_);
if (this->send_ != nullptr)
noise_cipherstate_free(this->send_);
if (this->recv_ != nullptr)
noise_cipherstate_free(this->recv_);
}
Initiator(const Initiator &) = delete;
Initiator &operator=(const Initiator &) = delete;
size_t write_message(uint8_t *out, size_t capacity) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, out, capacity);
EXPECT_EQ(noise_handshakestate_write_message(this->state_, &mbuf, nullptr), 0);
return mbuf.size;
}
int read_message(uint8_t *data, size_t len) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, data, len);
return noise_handshakestate_read_message(this->state_, &mbuf, nullptr);
}
void split() { EXPECT_EQ(noise_handshakestate_split(this->state_, &this->send_, &this->recv_), 0); }
NoiseCipherState *send_{nullptr};
NoiseCipherState *recv_{nullptr};
private:
NoiseHandshakeState *state_{nullptr};
};
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
static psk_t make_psk(uint8_t seed) {
psk_t psk;
for (size_t i = 0; i < psk.size(); i++) {
psk[i] = static_cast<uint8_t>(seed + i);
}
return psk;
}
TEST(NoiseResponderHandshakeTest, ActionFailedBeforeInit) {
NoiseResponderHandshake handshake;
EXPECT_EQ(handshake.action(), Action::ACTION_FAILED);
}
TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) {
// The class doc promises a noise-c error, not a crash, when the message
// methods run outside their action() step; pin the library's null check
NoiseResponderHandshake handshake;
uint8_t buf[MAX_HANDSHAKE_SIZE] = {};
size_t out_len = 0;
EXPECT_NE(handshake.read_message(buf, sizeof(buf)), 0);
EXPECT_NE(handshake.write_message(buf, sizeof(buf), out_len), 0);
// Deliberately non-null: split() documents a nullptr postcondition on
// error, so a caller's uninitialized locals never hold garbage to free
auto *sentinel = reinterpret_cast<NoiseCipherState *>(0x1);
NoiseCipherState *send_cipher = sentinel;
NoiseCipherState *recv_cipher = sentinel;
EXPECT_NE(handshake.split(send_cipher, recv_cipher), 0);
EXPECT_EQ(send_cipher, nullptr);
EXPECT_EQ(recv_cipher, nullptr);
}
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
ASSERT_EQ(responder.read_message(msg, msg_len), 0);
ASSERT_EQ(responder.action(), Action::ACTION_WRITE);
size_t reply_len = 0;
ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0);
ASSERT_GT(reply_len, 0u);
ASSERT_EQ(responder.action(), Action::ACTION_SPLIT);
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);
ASSERT_NE(recv_cipher, nullptr);
// The handshake state is released by split(); the class reports FAILED after
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
EXPECT_EQ(static_cast<size_t>(noise_cipherstate_get_mac_length(send_cipher)), MAC_SIZE);
// Responder encrypts, initiator decrypts
uint8_t frame[64];
static constexpr char PLAINTEXT[] = "encrypted ota";
std::memcpy(frame, PLAINTEXT, sizeof(PLAINTEXT));
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, frame, sizeof(PLAINTEXT), sizeof(frame));
ASSERT_EQ(noise_cipherstate_encrypt(send_cipher, &mbuf), 0);
EXPECT_EQ(mbuf.size, sizeof(PLAINTEXT) + MAC_SIZE);
noise_buffer_set_inout(mbuf, frame, mbuf.size, sizeof(frame));
ASSERT_EQ(noise_cipherstate_decrypt(initiator.recv_, &mbuf), 0);
ASSERT_EQ(mbuf.size, sizeof(PLAINTEXT));
EXPECT_EQ(std::memcmp(frame, PLAINTEXT, sizeof(PLAINTEXT)), 0);
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 (ASAN in the test harness guards the single free)
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
}
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
int err = responder.read_message(msg, msg_len);
EXPECT_EQ(err, NOISE_ERROR_MAC_FAILURE);
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
}
TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) {
// The prologue binds the plaintext preamble for downgrade resistance; a
// tampered preamble must fail even with the right key.
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
static const uint8_t TAMPERED[] = {'x'};
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
EXPECT_EQ(responder.read_message(msg, msg_len), NOISE_ERROR_MAC_FAILURE);
}
} // namespace esphome::noise::testing
@@ -0,0 +1,74 @@
#include <gtest/gtest.h>
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
namespace esphome::noise::testing {
TEST(NoiseContextTest, AllZerosPskIsReserved) {
psk_t zeros{};
EXPECT_TRUE(NoiseContext::is_all_zeros(zeros));
psk_t psk{};
psk[31] = 1;
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
NoiseContext ctx;
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(zeros);
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(psk);
EXPECT_TRUE(ctx.has_psk());
EXPECT_EQ(ctx.get_psk(), psk);
}
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
uint8_t header[FRAME_HEADER_SIZE];
write_frame_header(header, 0x1234);
EXPECT_EQ(header[0], FRAME_INDICATOR);
EXPECT_EQ(header[1], 0x12);
EXPECT_EQ(header[2], 0x34);
}
TEST(WireFormatTest, RejectPayloadCarriesStatusByteAndMacFailureContract) {
// The MAC failure string is a wire contract: clients match it to report a
// wrong key. Format the payload exactly the way the handshake read path does.
uint8_t buf[64];
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
static constexpr char EXPECTED[] = "Handshake MAC failure";
ASSERT_EQ(len, 1 + strlen(EXPECTED));
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
EXPECT_EQ(memcmp(buf + 1, EXPECTED, strlen(EXPECTED)), 0);
// The exported floor covers the full MAC failure payload exactly
EXPECT_EQ(MAC_FAILURE_PAYLOAD_SIZE, 1 + strlen(EXPECTED));
// Any other error maps to the generic reason
len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_INVALID_STATE));
static constexpr char GENERIC[] = "Handshake error";
ASSERT_EQ(len, 1 + strlen(GENERIC));
EXPECT_EQ(memcmp(buf + 1, GENERIC, strlen(GENERIC)), 0);
}
TEST(WireFormatTest, RejectPayloadTruncatesToCapacity) {
uint8_t buf[8];
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
ASSERT_EQ(len, sizeof(buf));
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
EXPECT_EQ(memcmp(buf + 1, "Handsha", 7), 0);
// A one-byte buffer still carries the status byte
uint8_t tiny[1];
len = format_reject_payload(tiny, sizeof(tiny), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
ASSERT_EQ(len, 1u);
EXPECT_EQ(tiny[0], HANDSHAKE_STATUS_REJECT);
// A zero-capacity buffer yields no payload and stays untouched
uint8_t none[1] = {0xAA};
EXPECT_EQ(format_reject_payload(none, 0, reject_reason_for(NOISE_ERROR_MAC_FAILURE)), 0u);
EXPECT_EQ(none[0], 0xAA);
}
} // namespace esphome::noise::testing