[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
+3 -1
View File
@@ -1754,11 +1754,13 @@ void APIConnection::complete_authentication_() {
#ifdef USE_API_NOISE
void APIConnection::send_resume_ticket_() {
#ifdef USE_API_PLAINTEXT
// Only encrypted transports get a ticket: on dual-mode builds a plaintext
// connection has no frame footer
if (this->helper_->frame_footer_size() == 0) {
return;
}
#endif
noise::ResumeTicket ticket;
if (!this->parent_->get_noise_ctx().resume_cache().issue(ticket)) {
return;
@@ -1767,7 +1769,7 @@ void APIConnection::send_resume_ticket_() {
msg.set_session_id(ticket.session_id, noise::RESUME_SESSION_ID_SIZE);
msg.set_secret(ticket.secret, noise::RESUME_SECRET_SIZE);
this->send_message(msg);
noise::resume_wipe(&ticket, sizeof(ticket));
noise_clean(&ticket, sizeof(ticket));
}
#endif
@@ -283,6 +283,7 @@ class APIFrameHelper {
CLOSED = 6,
FAILED = 7,
EXPLICIT_REJECT = 8, // Noise only
RESUME_DISCARD = 9, // Noise only: session resumed, discard the in-flight handshake msg 1
};
// Fast inline state check for read_packet/write_protobuf_messages hot path.
@@ -252,6 +252,8 @@ APIError APINoiseFrameHelper::state_action_() {
return this->state_action_server_hello_();
case State::HANDSHAKE:
return this->state_action_handshake_();
case State::RESUME_DISCARD:
return this->state_action_resume_discard_();
case State::CLOSED:
case State::FAILED:
return APIError::BAD_STATE;
@@ -277,29 +279,19 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size);
}
// A resume offer is decided on in the server hello step, which reads it
// from rx_buf_ (no frame is read in between, so the buffer stays intact).
this->resume_offer_pending_ = rx_size == noise::RESUME_OFFER_SIZE && this->rx_buf_[0] == noise::RESUME_OFFER_VERSION;
state_ = State::SERVER_HELLO;
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_server_hello_() {
// A verified resume offer replaces the whole handshake: consume the ticket,
// prove possession of its secret in a trailing ServerHello extension (old
// clients ignore trailing bytes), and derive the transport keys via HKDF.
// Every failure on this path silently falls back to the full handshake.
uint8_t resume_secret[noise::RESUME_SECRET_SIZE];
uint8_t server_nonce[noise::RESUME_NONCE_SIZE];
uint8_t confirm_mac[noise::RESUME_MAC_SIZE];
bool resume =
this->resume_offer_pending_ && this->ctx_.resume_cache().take_verified(this->rx_buf_.data(), resume_secret);
this->resume_offer_pending_ = false;
if (resume) {
resume = random_bytes(server_nonce, sizeof(server_nonce)) &&
noise::resume_compute_confirm_mac(resume_secret, this->rx_buf_.data() + noise::RESUME_OFFER_NONCE_OFFSET,
server_nonce, confirm_mac);
}
// A verified resume offer (still in rx_buf_ from the client hello step; no
// frame is read in between) replaces the whole handshake: the cache
// consumes the ticket, proves possession of its secret in a trailing
// ServerHello extension (old clients ignore trailing bytes), and hands
// back ready transport ciphers. Every failure silently falls back to the
// full handshake.
uint8_t resume_ext[noise::RESUME_ACCEPT_SIZE];
bool resume = this->ctx_.resume_cache().try_accept(this->rx_buf_.data(), this->rx_buf_.size(), this->prologue_.data(),
this->prologue_.size(), resume_ext, send_cipher_, recv_cipher_);
// send server hello
const auto &name = App.get_name();
@@ -328,26 +320,22 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
if (resume) {
// version | server_nonce | confirm_mac
uint8_t *ext = msg + total_size;
ext[0] = noise::RESUME_ACCEPT_VERSION;
std::memcpy(ext + 1, server_nonce, sizeof(server_nonce));
std::memcpy(ext + 1 + sizeof(server_nonce), confirm_mac, sizeof(confirm_mac));
std::memcpy(msg + total_size, resume_ext, noise::RESUME_ACCEPT_SIZE);
total_size += noise::RESUME_ACCEPT_SIZE;
}
APIError aerr = write_frame_(msg, total_size);
if (aerr != APIError::OK) {
noise::resume_wipe(resume_secret, sizeof(resume_secret));
if (aerr != APIError::OK)
return aerr;
}
if (resume) {
aerr = this->setup_resumed_session_(resume_secret, server_nonce);
noise::resume_wipe(resume_secret, sizeof(resume_secret));
return aerr;
// The client's full-handshake message 1 is already in flight; discard
// it before entering DATA.
this->prologue_.release();
this->frame_footer_size_ = noise_cipherstate_get_mac_length(this->send_cipher_);
state_ = State::RESUME_DISCARD;
return APIError::OK;
}
noise::resume_wipe(resume_secret, sizeof(resume_secret));
// start handshake
aerr = init_handshake_();
@@ -357,39 +345,7 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
state_ = State::HANDSHAKE;
return APIError::OK;
}
/// Derive the resumed session's transport ciphers. The client's full
/// handshake message 1 is already in flight, so the connection stays in
/// HANDSHAKE state to read and discard it before switching to DATA.
APIError APINoiseFrameHelper::setup_resumed_session_(const uint8_t *resume_secret, const uint8_t *server_nonce) {
uint8_t k_c2d[32];
uint8_t k_d2c[32];
bool ok = noise::resume_derive_keys(resume_secret, this->rx_buf_.data() + noise::RESUME_OFFER_NONCE_OFFSET,
server_nonce, this->prologue_.data(), this->prologue_.size(), k_c2d, k_d2c);
if (ok) {
this->recv_cipher_ = noise::resume_make_cipher(k_c2d);
this->send_cipher_ = noise::resume_make_cipher(k_d2c);
ok = this->recv_cipher_ != nullptr && this->send_cipher_ != nullptr;
}
noise::resume_wipe(k_c2d, sizeof(k_c2d));
noise::resume_wipe(k_d2c, sizeof(k_d2c));
if (!ok) {
// The accept extension is already on the wire; the connection cannot
// fall back to a full handshake any more. Fail it; the client retries.
state_ = State::FAILED;
HELPER_LOG("Resume key derivation failed");
return APIError::HANDSHAKESTATE_SETUP_FAILED;
}
this->prologue_.release();
this->frame_footer_size_ = noise_cipherstate_get_mac_length(this->send_cipher_);
this->resume_discard_msg1_ = true;
state_ = State::HANDSHAKE;
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_handshake_() {
if (this->resume_discard_msg1_) {
return this->state_action_resume_discard_();
}
noise::NoiseResponderHandshake::Action action = this->handshake_.action();
if (action == noise::NoiseResponderHandshake::Action::ACTION_READ) {
return this->state_action_handshake_read_();
@@ -414,7 +370,6 @@ APIError APINoiseFrameHelper::state_action_resume_discard_() {
HELPER_LOG("Bad discarded handshake message");
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
}
this->resume_discard_msg1_ = false;
HELPER_LOG("Session resumed!");
state_ = State::DATA;
return APIError::OK;
@@ -43,7 +43,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
APIError state_action_handshake_read_();
APIError state_action_handshake_write_();
APIError state_action_resume_discard_();
APIError setup_resumed_session_(const uint8_t *resume_secret, const uint8_t *server_nonce);
APIError try_read_frame_();
APIError write_frame_(const uint8_t *data, uint16_t len);
APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
@@ -71,14 +70,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase
uint8_t rx_header_buf_[noise::FRAME_HEADER_SIZE];
uint8_t rx_header_buf_len_ = 0;
// The ClientHello body carried a well-formed resume offer; decided on in
// state_action_server_hello_, which reads the offer from the still-intact
// rx_buf_.
bool resume_offer_pending_ = false;
// Resume accepted: the client's already-in-flight full-handshake message 1
// must be read and discarded before the connection enters DATA.
bool resume_discard_msg1_ = false;
// 6 bytes total, 2 padding
// 4 bytes total, no padding
};
} // namespace esphome::api
+63 -41
View File
@@ -2,26 +2,13 @@
#ifdef USE_NOISE
#include <cstring>
#include <noise/protocol.h>
#include "esphome/core/helpers.h"
namespace esphome::noise {
void resume_wipe(void *p, size_t len) {
volatile uint8_t *b = reinterpret_cast<volatile uint8_t *>(p);
while (len--) {
*b++ = 0;
}
}
static bool resume_ct_equal_(const uint8_t *a, const uint8_t *b, size_t len) {
uint8_t acc = 0;
for (size_t i = 0; i < len; i++) {
acc |= a[i] ^ b[i];
}
return acc == 0;
}
/// Noise-construction HKDF-SHA256; out2 may alias scratch the caller wipes.
/// Noise-construction HKDF-SHA256; out2 may be scratch the caller wipes.
static bool resume_hkdf_(const uint8_t *key, size_t key_len, const uint8_t *data, size_t data_len, uint8_t *out1,
size_t out1_len, uint8_t *out2, size_t out2_len) {
NoiseHashState *hash = nullptr;
@@ -33,8 +20,10 @@ static bool resume_hkdf_(const uint8_t *key, size_t key_len, const uint8_t *data
return err == NOISE_ERROR_NONE;
}
static bool resume_mac_(const uint8_t *secret, const char *label, size_t label_len, const uint8_t *a, size_t a_len,
const uint8_t *b, size_t b_len, uint8_t *out_mac) {
template<size_t N>
static bool resume_mac_(const uint8_t *secret, const char (&label)[N], const uint8_t *a, size_t a_len, const uint8_t *b,
size_t b_len, uint8_t *out_mac) {
constexpr size_t label_len = N - 1; // drop the terminating NUL
// label || a || b, largest use is "confirm"(7) + 16 + 16 = 39
uint8_t data[7 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE];
uint8_t scratch[32];
@@ -43,63 +32,96 @@ static bool resume_mac_(const uint8_t *secret, const char *label, size_t label_l
std::memcpy(data + label_len + a_len, b, b_len);
bool ok = resume_hkdf_(secret, RESUME_SECRET_SIZE, data, label_len + a_len + b_len, out_mac, RESUME_MAC_SIZE, scratch,
sizeof(scratch));
resume_wipe(scratch, sizeof(scratch));
noise_clean(scratch, sizeof(scratch));
return ok;
}
bool ResumeTicketCache::issue(ResumeTicket &out) {
ResumeTicket ticket;
if (!random_bytes(ticket.session_id, RESUME_SESSION_ID_SIZE) || !random_bytes(ticket.secret, RESUME_SECRET_SIZE)) {
if (!random_bytes(out.session_id, RESUME_SESSION_ID_SIZE) || !random_bytes(out.secret, RESUME_SECRET_SIZE)) {
return false;
}
ticket.valid = true;
ResumeTicket &slot = this->slots_[this->next_];
this->next_ = static_cast<uint8_t>((this->next_ + 1) % SLOTS);
slot = ticket;
out = ticket;
resume_wipe(&ticket, sizeof(ticket));
uint8_t slot = this->next_;
this->next_ = static_cast<uint8_t>((slot + 1) % SLOTS);
this->slots_[slot] = out;
this->used_[slot] = true;
return true;
}
bool ResumeTicketCache::take_verified(const uint8_t *offer, uint8_t *secret_out) {
bool ResumeTicketCache::take_verified_(const uint8_t *offer, uint8_t *secret_out) {
const uint8_t *session_id = offer + RESUME_OFFER_SESSION_ID_OFFSET;
const uint8_t *client_nonce = offer + RESUME_OFFER_NONCE_OFFSET;
const uint8_t *offer_mac = offer + RESUME_OFFER_MAC_OFFSET;
for (ResumeTicket &slot : this->slots_) {
if (!slot.valid || std::memcmp(slot.session_id, session_id, RESUME_SESSION_ID_SIZE) != 0) {
for (uint8_t i = 0; i < SLOTS; i++) {
ResumeTicket &slot = this->slots_[i];
if (!this->used_[i] || std::memcmp(slot.session_id, session_id, RESUME_SESSION_ID_SIZE) != 0) {
continue;
}
uint8_t expected[RESUME_MAC_SIZE];
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));
noise_is_equal(expected, offer_mac, RESUME_MAC_SIZE);
noise_clean(expected, sizeof(expected));
if (!ok) {
// Bad MAC: leave the ticket so a forger cannot burn it
return false;
}
std::memcpy(secret_out, slot.secret, RESUME_SECRET_SIZE);
resume_wipe(&slot, sizeof(slot));
slot.valid = false;
noise_clean(&slot, sizeof(slot));
this->used_[i] = false;
return true;
}
return false;
}
void ResumeTicketCache::clear() {
resume_wipe(this->slots_, sizeof(this->slots_));
for (ResumeTicket &slot : this->slots_) {
slot.valid = false;
bool ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue, size_t prologue_len,
uint8_t *out_ext, NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher) {
if (offer_len != RESUME_OFFER_SIZE || offer[0] != RESUME_OFFER_VERSION) {
return false;
}
uint8_t secret[RESUME_SECRET_SIZE];
if (!this->take_verified_(offer, secret)) {
return false;
}
// From here every failure still falls back to the full handshake; the
// ticket is spent, which is harmless (the client gets a fresh one).
const uint8_t *client_nonce = offer + RESUME_OFFER_NONCE_OFFSET;
uint8_t *server_nonce = out_ext + 1;
uint8_t *confirm_mac = out_ext + 1 + RESUME_NONCE_SIZE;
uint8_t k_c2d[32];
uint8_t k_d2c[32];
out_ext[0] = RESUME_ACCEPT_VERSION;
bool ok = random_bytes(server_nonce, RESUME_NONCE_SIZE) &&
resume_compute_confirm_mac(secret, client_nonce, server_nonce, confirm_mac) &&
resume_derive_keys(secret, client_nonce, server_nonce, prologue, prologue_len, k_c2d, k_d2c);
noise_clean(secret, sizeof(secret));
if (ok) {
recv_cipher = resume_make_cipher(k_c2d);
send_cipher = resume_make_cipher(k_d2c);
ok = recv_cipher != nullptr && send_cipher != nullptr;
if (!ok) {
noise_cipherstate_free(recv_cipher);
noise_cipherstate_free(send_cipher);
recv_cipher = nullptr;
send_cipher = nullptr;
}
}
noise_clean(k_c2d, sizeof(k_c2d));
noise_clean(k_d2c, sizeof(k_d2c));
return ok;
}
void ResumeTicketCache::clear() {
noise_clean(this->slots_, sizeof(this->slots_));
std::memset(this->used_, 0, sizeof(this->used_));
}
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);
return resume_mac_(secret, "offer", 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);
return resume_mac_(secret, "confirm", client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, out_mac);
}
bool resume_derive_keys(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce,
@@ -118,7 +140,7 @@ bool resume_derive_keys(const uint8_t *secret, const uint8_t *client_nonce, cons
err = noise_hashstate_hkdf(hash, secret, RESUME_SECRET_SIZE, data, sizeof(data), k_c2d, 32, k_d2c, 32);
}
noise_hashstate_free(hash);
resume_wipe(data, sizeof(data));
noise_clean(data, sizeof(data));
return err == NOISE_ERROR_NONE;
}
+21 -13
View File
@@ -4,7 +4,11 @@
#include <cstddef>
#include <cstdint>
#include <noise/protocol.h>
// Forward declaration matching <noise/protocol/cipherstate.h>; keeps noise-c
// headers out of everything that includes noise.h.
extern "C" {
typedef struct NoiseCipherState_s NoiseCipherState; // NOLINT(modernize-use-using)
}
namespace esphome::noise {
@@ -48,35 +52,39 @@ static constexpr size_t RESUME_ACCEPT_SIZE = 1 + RESUME_NONCE_SIZE + RESUME_MAC_
struct ResumeTicket {
uint8_t session_id[RESUME_SESSION_ID_SIZE];
uint8_t secret[RESUME_SECRET_SIZE];
bool valid{false};
};
/// Fixed-slot RAM cache of single-use resume tickets. Lost on reboot by
/// design: clients fall back to a full handshake.
class ResumeTicketCache {
public:
/// Generate and store a fresh ticket, evicting the oldest slot.
/// Returns false (and stores nothing) if the RNG fails.
/// Generate a fresh ticket into out and store it, evicting the oldest
/// slot. Returns false (and stores nothing) if the RNG fails.
bool issue(ResumeTicket &out);
/// Verify a wire offer (RESUME_OFFER_SIZE bytes, version already checked).
/// On a valid MAC the ticket is consumed (single use) and its secret is
/// copied to secret_out. A miss or a bad MAC leaves the cache unchanged so
/// an attacker cannot burn tickets.
bool take_verified(const uint8_t *offer, uint8_t *secret_out);
/// Try to accept a resume offer: recognizes the offer format, verifies
/// its MAC against a cached ticket (consuming it, single use), derives
/// the transport keys bound to the prologue, builds both ChaChaPoly
/// cipher states, and fills the RESUME_ACCEPT_SIZE ServerHello extension
/// proving possession of the secret. A miss, a bad MAC, or any internal
/// failure returns false with nothing allocated, and a forged offer never
/// burns a ticket. k_c2d decrypts client-to-device traffic (recv), k_d2c
/// encrypts device-to-client (send). All secrets are wiped internally.
bool try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue, size_t prologue_len,
uint8_t *out_ext, NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher);
/// Forget every ticket (PSK change).
void clear();
protected:
bool take_verified_(const uint8_t *offer, uint8_t *secret_out);
static constexpr uint8_t SLOTS = 4;
ResumeTicket slots_[SLOTS];
bool used_[SLOTS]{};
uint8_t next_{0};
};
/// 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).
/// try_accept checks).
bool resume_compute_offer_mac(const uint8_t *secret, const uint8_t *session_id, const uint8_t *client_nonce,
uint8_t *out_mac);
+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