From b60950da76bcb92ab1b0856f1d97779f77921b1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 16:44:42 -0500 Subject: [PATCH] [noise] Add known answer and cache tests for session resume --- esphome/components/noise/noise_resume.cpp | 8 +- esphome/components/noise/noise_resume.h | 5 + tests/components/noise/test_noise_resume.cpp | 156 +++++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/components/noise/test_noise_resume.cpp diff --git a/esphome/components/noise/noise_resume.cpp b/esphome/components/noise/noise_resume.cpp index 007220df25..36e14b25e7 100644 --- a/esphome/components/noise/noise_resume.cpp +++ b/esphome/components/noise/noise_resume.cpp @@ -70,8 +70,7 @@ bool ResumeTicketCache::take_verified(const uint8_t *offer, uint8_t *secret_out) continue; } uint8_t expected[RESUME_MAC_SIZE]; - bool ok = resume_mac_(slot.secret, "offer", 5, session_id, RESUME_SESSION_ID_SIZE, client_nonce, RESUME_NONCE_SIZE, - expected) && + bool ok = resume_compute_offer_mac(slot.secret, session_id, client_nonce, expected) && resume_ct_equal_(expected, offer_mac, RESUME_MAC_SIZE); resume_wipe(expected, sizeof(expected)); if (!ok) { @@ -93,6 +92,11 @@ void ResumeTicketCache::clear() { } } +bool resume_compute_offer_mac(const uint8_t *secret, const uint8_t *session_id, const uint8_t *client_nonce, + uint8_t *out_mac) { + return resume_mac_(secret, "offer", 5, session_id, RESUME_SESSION_ID_SIZE, client_nonce, RESUME_NONCE_SIZE, out_mac); +} + bool resume_compute_confirm_mac(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce, uint8_t *out_mac) { return resume_mac_(secret, "confirm", 7, client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, out_mac); diff --git a/esphome/components/noise/noise_resume.h b/esphome/components/noise/noise_resume.h index c037ce672e..c4999f3e3e 100644 --- a/esphome/components/noise/noise_resume.h +++ b/esphome/components/noise/noise_resume.h @@ -75,6 +75,11 @@ class ResumeTicketCache { /// Best-effort secure wipe (not optimized away). void resume_wipe(void *p, size_t len); +/// offer_mac for the ClientHello resume offer (what a client computes and +/// take_verified checks). +bool resume_compute_offer_mac(const uint8_t *secret, const uint8_t *session_id, const uint8_t *client_nonce, + uint8_t *out_mac); + /// confirm_mac for the ServerHello extension. bool resume_compute_confirm_mac(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce, uint8_t *out_mac); diff --git a/tests/components/noise/test_noise_resume.cpp b/tests/components/noise/test_noise_resume.cpp new file mode 100644 index 0000000000..d6543ac16d --- /dev/null +++ b/tests/components/noise/test_noise_resume.cpp @@ -0,0 +1,156 @@ +#include + +#include + +#include "esphome/components/noise/noise.h" +#include "esphome/components/noise/noise_resume.h" + +namespace esphome::noise::testing { + +// Known-answer vectors shared with the client implementation +// (aioesphomeapi tests/test_noise_resume.py); the two must stay identical +// byte for byte or resumed sessions cannot interoperate. +static const uint8_t KAT_SECRET[RESUME_SECRET_SIZE] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}; +static const uint8_t KAT_SESSION_ID[RESUME_SESSION_ID_SIZE] = {0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7}; + +static void fill_nonces(uint8_t *client_nonce, uint8_t *server_nonce) { + for (int i = 0; i < 16; i++) { + client_nonce[i] = 0x10 + i; + server_nonce[i] = 0x30 + i; + } +} + +static void build_kat_offer(uint8_t *offer, const uint8_t *client_nonce, const uint8_t *offer_mac) { + offer[0] = RESUME_OFFER_VERSION; + std::memcpy(offer + RESUME_OFFER_SESSION_ID_OFFSET, KAT_SESSION_ID, RESUME_SESSION_ID_SIZE); + std::memcpy(offer + RESUME_OFFER_NONCE_OFFSET, client_nonce, RESUME_NONCE_SIZE); + std::memcpy(offer + RESUME_OFFER_MAC_OFFSET, offer_mac, RESUME_MAC_SIZE); +} + +static const uint8_t KAT_OFFER_MAC[RESUME_MAC_SIZE] = {0xa8, 0x08, 0xea, 0xdb, 0xec, 0x81, 0xa7, 0xcb, + 0xf4, 0xca, 0xaa, 0xb8, 0x0d, 0x7f, 0x9d, 0x01}; +static const uint8_t KAT_CONFIRM_MAC[RESUME_MAC_SIZE] = {0x09, 0xa3, 0x70, 0x3e, 0xc8, 0x34, 0x77, 0xe9, + 0x45, 0xe7, 0xf1, 0x61, 0x9d, 0x4f, 0x6a, 0x76}; +static const uint8_t KAT_K_C2D[32] = {0xd6, 0x01, 0xe3, 0xc1, 0x16, 0xa1, 0x64, 0x66, 0xdb, 0xc5, 0x9e, + 0xdd, 0x60, 0x2a, 0x64, 0x1e, 0xbe, 0xf5, 0x11, 0x95, 0x98, 0xd2, + 0xf2, 0x47, 0x1b, 0xc6, 0x8c, 0x51, 0x8f, 0xbe, 0xb7, 0x23}; +static const uint8_t KAT_K_D2C[32] = {0x7f, 0x8d, 0x57, 0x7e, 0x9f, 0xb4, 0xbb, 0xde, 0x86, 0xcd, 0xa9, + 0xf4, 0x9b, 0x42, 0xe7, 0x24, 0xc8, 0x49, 0xce, 0x89, 0xd8, 0x96, + 0x3f, 0x3c, 0x4b, 0x3f, 0x8f, 0x80, 0xc2, 0x56, 0xab, 0x65}; + +TEST(NoiseResumeKat, ConfirmMacMatchesClientImplementation) { + uint8_t client_nonce[16], server_nonce[16], mac[RESUME_MAC_SIZE]; + fill_nonces(client_nonce, server_nonce); + ASSERT_TRUE(resume_compute_confirm_mac(KAT_SECRET, client_nonce, server_nonce, mac)); + EXPECT_EQ(std::memcmp(mac, KAT_CONFIRM_MAC, RESUME_MAC_SIZE), 0); +} + +TEST(NoiseResumeKat, KeyDerivationMatchesClientImplementation) { + uint8_t client_nonce[16], server_nonce[16]; + fill_nonces(client_nonce, server_nonce); + // Prologue used by the shared vectors: "NoiseAPIInit" + be16(41) + a + // 41-byte offer whose MAC field is 16 bytes of 0xEE + uint8_t prologue[55]; + std::memcpy(prologue, "NoiseAPIInit", 12); + prologue[12] = 0x00; + prologue[13] = 0x29; + uint8_t mac_filler[RESUME_MAC_SIZE]; + std::memset(mac_filler, 0xEE, sizeof(mac_filler)); + build_kat_offer(prologue + 14, client_nonce, mac_filler); + + uint8_t k_c2d[32], k_d2c[32]; + ASSERT_TRUE(resume_derive_keys(KAT_SECRET, client_nonce, server_nonce, prologue, sizeof(prologue), k_c2d, k_d2c)); + EXPECT_EQ(std::memcmp(k_c2d, KAT_K_C2D, 32), 0); + EXPECT_EQ(std::memcmp(k_d2c, KAT_K_D2C, 32), 0); +} + +TEST(NoiseResumeCache, TakeVerifiedConsumesTicketOnce) { + ResumeTicketCache cache; + // Plant the KAT ticket directly through the protected members via a + // subclass so the test controls the session id and secret. + struct TestCache : ResumeTicketCache { + void plant(const uint8_t *session_id, const uint8_t *secret) { + std::memcpy(this->slots_[0].session_id, session_id, RESUME_SESSION_ID_SIZE); + std::memcpy(this->slots_[0].secret, secret, RESUME_SECRET_SIZE); + this->slots_[0].valid = true; + } + } test_cache; + test_cache.plant(KAT_SESSION_ID, KAT_SECRET); + + uint8_t client_nonce[16], server_nonce[16]; + fill_nonces(client_nonce, server_nonce); + uint8_t offer[RESUME_OFFER_SIZE]; + build_kat_offer(offer, client_nonce, KAT_OFFER_MAC); + + uint8_t secret[RESUME_SECRET_SIZE]; + ASSERT_TRUE(test_cache.take_verified(offer, secret)); + EXPECT_EQ(std::memcmp(secret, KAT_SECRET, RESUME_SECRET_SIZE), 0); + // Single use: the same offer must miss the second time + EXPECT_FALSE(test_cache.take_verified(offer, secret)); +} + +TEST(NoiseResumeCache, BadMacLeavesTicketIntact) { + struct TestCache : ResumeTicketCache { + void plant(const uint8_t *session_id, const uint8_t *secret) { + std::memcpy(this->slots_[0].session_id, session_id, RESUME_SESSION_ID_SIZE); + std::memcpy(this->slots_[0].secret, secret, RESUME_SECRET_SIZE); + this->slots_[0].valid = true; + } + } test_cache; + test_cache.plant(KAT_SESSION_ID, KAT_SECRET); + + uint8_t client_nonce[16], server_nonce[16]; + fill_nonces(client_nonce, server_nonce); + uint8_t offer[RESUME_OFFER_SIZE]; + uint8_t bad_mac[RESUME_MAC_SIZE]; + std::memcpy(bad_mac, KAT_OFFER_MAC, RESUME_MAC_SIZE); + bad_mac[0] ^= 0x01; + build_kat_offer(offer, client_nonce, bad_mac); + + uint8_t secret[RESUME_SECRET_SIZE]; + // A forged offer must not burn the ticket + EXPECT_FALSE(test_cache.take_verified(offer, secret)); + build_kat_offer(offer, client_nonce, KAT_OFFER_MAC); + EXPECT_TRUE(test_cache.take_verified(offer, secret)); +} + +static void build_offer_for_ticket(uint8_t *offer, const ResumeTicket &ticket, const uint8_t *client_nonce) { + offer[0] = RESUME_OFFER_VERSION; + std::memcpy(offer + RESUME_OFFER_SESSION_ID_OFFSET, ticket.session_id, RESUME_SESSION_ID_SIZE); + std::memcpy(offer + RESUME_OFFER_NONCE_OFFSET, client_nonce, RESUME_NONCE_SIZE); + ASSERT_TRUE( + resume_compute_offer_mac(ticket.secret, ticket.session_id, client_nonce, offer + RESUME_OFFER_MAC_OFFSET)); +} + +TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) { + ResumeTicketCache cache; + ResumeTicket tickets[5]; + for (auto &ticket : tickets) { + ASSERT_TRUE(cache.issue(ticket)); + ASSERT_TRUE(ticket.valid); + } + uint8_t client_nonce[16], server_nonce[16]; + fill_nonces(client_nonce, server_nonce); + uint8_t offer[RESUME_OFFER_SIZE]; + uint8_t secret[RESUME_SECRET_SIZE]; + + // Slot 0 was evicted by the fifth issue + build_offer_for_ticket(offer, tickets[0], client_nonce); + EXPECT_FALSE(cache.take_verified(offer, secret)); + // Tickets 1..4 remain redeemable + for (int i = 1; i < 5; i++) { + build_offer_for_ticket(offer, tickets[i], client_nonce); + EXPECT_TRUE(cache.take_verified(offer, secret)); + EXPECT_EQ(std::memcmp(secret, tickets[i].secret, RESUME_SECRET_SIZE), 0); + } + + // clear() forgets everything + ResumeTicket ticket; + ASSERT_TRUE(cache.issue(ticket)); + cache.clear(); + build_offer_for_ticket(offer, ticket, client_nonce); + EXPECT_FALSE(cache.take_verified(offer, secret)); +} + +} // namespace esphome::noise::testing