[noise] Simplify the resume implementation

This commit is contained in:
J. Nick Koston
2026-08-23 17:28:30 -05:00
parent 33f56661f2
commit fc5c7149fa
7 changed files with 221 additions and 205 deletions
+113 -77
View File
@@ -2,6 +2,8 @@
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
#include "esphome/components/noise/noise_resume.h"
@@ -13,21 +15,10 @@ namespace esphome::noise::testing {
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_CLIENT_NONCE[RESUME_NONCE_SIZE] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f};
static const uint8_t KAT_SERVER_NONCE[RESUME_NONCE_SIZE] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f};
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,
@@ -39,88 +30,132 @@ static const uint8_t KAT_K_D2C[32] = {0x7f, 0x8d, 0x57, 0x7e, 0x9f, 0xb4, 0xbb,
0xf4, 0x9b, 0x42, 0xe7, 0x24, 0xc8, 0x49, 0xce, 0x89, 0xd8, 0x96,
0x3f, 0x3c, 0x4b, 0x3f, 0x8f, 0x80, 0xc2, 0x56, 0xab, 0x65};
/// The one place in this file that spells the offer wire layout
static void build_offer(uint8_t *offer, const uint8_t *session_id, const uint8_t *client_nonce, const uint8_t *mac) {
offer[0] = RESUME_OFFER_VERSION;
std::memcpy(offer + RESUME_OFFER_SESSION_ID_OFFSET, 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, mac, RESUME_MAC_SIZE);
}
static void build_offer_for_ticket(uint8_t *offer, const ResumeTicket &ticket, const uint8_t *client_nonce) {
uint8_t mac[RESUME_MAC_SIZE];
ASSERT_TRUE(resume_compute_offer_mac(ticket.secret, ticket.session_id, client_nonce, mac));
build_offer(offer, ticket.session_id, client_nonce, mac);
}
/// Test access to the protected slots so a test can plant the KAT ticket
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->used_[0] = true;
}
};
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));
uint8_t mac[RESUME_MAC_SIZE];
ASSERT_TRUE(resume_compute_confirm_mac(KAT_SECRET, KAT_CLIENT_NONCE, KAT_SERVER_NONCE, mac));
EXPECT_EQ(std::memcmp(mac, KAT_CONFIRM_MAC, RESUME_MAC_SIZE), 0);
}
TEST(NoiseResumeKat, OfferMacMatchesClientImplementation) {
uint8_t mac[RESUME_MAC_SIZE];
ASSERT_TRUE(resume_compute_offer_mac(KAT_SECRET, KAT_SESSION_ID, KAT_CLIENT_NONCE, mac));
EXPECT_EQ(std::memcmp(mac, KAT_OFFER_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];
uint8_t prologue[14 + RESUME_OFFER_SIZE];
std::memcpy(prologue, "NoiseAPIInit", 12);
prologue[12] = 0x00;
prologue[13] = 0x29;
prologue[13] = RESUME_OFFER_SIZE;
uint8_t mac_filler[RESUME_MAC_SIZE];
std::memset(mac_filler, 0xEE, sizeof(mac_filler));
build_kat_offer(prologue + 14, client_nonce, mac_filler);
build_offer(prologue + 14, KAT_SESSION_ID, KAT_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));
ASSERT_TRUE(
resume_derive_keys(KAT_SECRET, KAT_CLIENT_NONCE, KAT_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);
TEST(NoiseResumeCache, TryAcceptConsumesTicketOnceAndProvesPossession) {
TestCache cache;
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);
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, KAT_OFFER_MAC);
uint8_t prologue[14 + RESUME_OFFER_SIZE];
std::memcpy(prologue, "NoiseAPIInit", 12);
prologue[12] = 0x00;
prologue[13] = RESUME_OFFER_SIZE;
std::memcpy(prologue + 14, offer, RESUME_OFFER_SIZE);
uint8_t ext[RESUME_ACCEPT_SIZE];
NoiseCipherState *send = nullptr, *recv = nullptr;
ASSERT_TRUE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
ASSERT_NE(send, nullptr);
ASSERT_NE(recv, nullptr);
// The extension proves possession: verify like the client does
EXPECT_EQ(ext[0], RESUME_ACCEPT_VERSION);
const uint8_t *server_nonce = ext + 1;
uint8_t expected_confirm[RESUME_MAC_SIZE];
ASSERT_TRUE(resume_compute_confirm_mac(KAT_SECRET, KAT_CLIENT_NONCE, server_nonce, expected_confirm));
EXPECT_EQ(std::memcmp(ext + 1 + RESUME_NONCE_SIZE, expected_confirm, RESUME_MAC_SIZE), 0);
// The ciphers must interoperate with the documented key derivation
uint8_t k_c2d[32], k_d2c[32];
ASSERT_TRUE(resume_derive_keys(KAT_SECRET, KAT_CLIENT_NONCE, server_nonce, prologue, sizeof(prologue), k_c2d, k_d2c));
NoiseCipherState *client_send = resume_make_cipher(k_c2d);
ASSERT_NE(client_send, nullptr);
uint8_t buf[64] = "resumed";
NoiseBuffer nb;
noise_buffer_init(nb);
noise_buffer_set_inout(nb, buf, 7, sizeof(buf));
ASSERT_EQ(noise_cipherstate_encrypt(client_send, &nb), NOISE_ERROR_NONE);
ASSERT_EQ(noise_cipherstate_decrypt(recv, &nb), NOISE_ERROR_NONE);
EXPECT_EQ(std::memcmp(buf, "resumed", 7), 0);
noise_cipherstate_free(client_send);
noise_cipherstate_free(send);
noise_cipherstate_free(recv);
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));
NoiseCipherState *send2 = nullptr, *recv2 = nullptr;
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send2, recv2));
EXPECT_EQ(send2, nullptr);
EXPECT_EQ(recv2, nullptr);
}
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);
TEST(NoiseResumeCache, BadMacOrMalformedOfferLeavesTicketIntact) {
TestCache cache;
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);
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, bad_mac);
uint8_t secret[RESUME_SECRET_SIZE];
uint8_t prologue[1] = {0};
uint8_t ext[RESUME_ACCEPT_SIZE];
NoiseCipherState *send = nullptr, *recv = nullptr;
// 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) {
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
// Wrong size or version must be recognized as "no offer"
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, KAT_OFFER_MAC);
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer) - 1, prologue, sizeof(prologue), ext, send, recv));
offer[0] = 0x7f;
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
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));
// The genuine offer still redeems
EXPECT_TRUE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
noise_cipherstate_free(send);
noise_cipherstate_free(recv);
}
TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) {
@@ -128,29 +163,30 @@ TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) {
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];
uint8_t prologue[1] = {0};
uint8_t ext[RESUME_ACCEPT_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));
build_offer_for_ticket(offer, tickets[0], KAT_CLIENT_NONCE);
NoiseCipherState *send = nullptr, *recv = nullptr;
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
// 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);
build_offer_for_ticket(offer, tickets[i], KAT_CLIENT_NONCE);
EXPECT_TRUE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
noise_cipherstate_free(send);
noise_cipherstate_free(recv);
send = recv = nullptr;
}
// 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));
build_offer_for_ticket(offer, ticket, KAT_CLIENT_NONCE);
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
}
} // namespace esphome::noise::testing