From fc5c7149fac0976688fcd78ff1e8c9d1deaab7ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 17:15:59 -0500 Subject: [PATCH] [noise] Simplify the resume implementation --- esphome/components/api/api_connection.cpp | 4 +- esphome/components/api/api_frame_helper.h | 1 + .../components/api/api_frame_helper_noise.cpp | 83 ++------ .../components/api/api_frame_helper_noise.h | 10 +- esphome/components/noise/noise_resume.cpp | 104 ++++++---- esphome/components/noise/noise_resume.h | 34 ++-- tests/components/noise/test_noise_resume.cpp | 190 +++++++++++------- 7 files changed, 221 insertions(+), 205 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0441709bf3..4699e40022 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -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 diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 1c60bb87a5..03fcfbe313 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -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. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 8275588ff6..dd05f27040 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -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; diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index e90369b1ef..feb706596f 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -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 diff --git a/esphome/components/noise/noise_resume.cpp b/esphome/components/noise/noise_resume.cpp index 36e14b25e7..6470652044 100644 --- a/esphome/components/noise/noise_resume.cpp +++ b/esphome/components/noise/noise_resume.cpp @@ -2,26 +2,13 @@ #ifdef USE_NOISE #include +#include + #include "esphome/core/helpers.h" namespace esphome::noise { -void resume_wipe(void *p, size_t len) { - volatile uint8_t *b = reinterpret_cast(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 +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((this->next_ + 1) % SLOTS); - slot = ticket; - out = ticket; - resume_wipe(&ticket, sizeof(ticket)); + uint8_t slot = this->next_; + this->next_ = static_cast((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; } diff --git a/esphome/components/noise/noise_resume.h b/esphome/components/noise/noise_resume.h index c4999f3e3e..72d6b66ce8 100644 --- a/esphome/components/noise/noise_resume.h +++ b/esphome/components/noise/noise_resume.h @@ -4,7 +4,11 @@ #include #include -#include +// Forward declaration matching ; 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); diff --git a/tests/components/noise/test_noise_resume.cpp b/tests/components/noise/test_noise_resume.cpp index d6543ac16d..4c4b8ac44f 100644 --- a/tests/components/noise/test_noise_resume.cpp +++ b/tests/components/noise/test_noise_resume.cpp @@ -2,6 +2,8 @@ #include +#include + #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