[noise] Simplify the resume cache accept path and guard the sensitive message set

This commit is contained in:
J. Nick Koston
2026-08-24 16:44:47 -05:00
parent 1cb2136c38
commit 46de7335b2
6 changed files with 97 additions and 72 deletions
+1
View File
@@ -897,6 +897,7 @@ message NoiseEncryptionSetKeyResponse {
// its next connection to skip the curve25519 handshake; the device then
// issues a fresh ticket on that connection. Never sent on plaintext
// connections. Clients that do not understand it drop it silently.
// Contents are secret; the device generator redacts this message from dump_to
message NoiseResumeTicket {
option (id) = 152;
option (source) = SOURCE_SERVER;
@@ -308,13 +308,12 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
// node mac, terminated by null byte
std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE);
// The accept extension is written straight after the mac
bool resume =
this->ctx_.resume_cache().try_accept(this->rx_buf_.data(), this->rx_buf_.size(), this->prologue_.data(),
this->prologue_.size(), msg + total_size, send_cipher_, recv_cipher_);
if (resume) {
total_size += noise::RESUME_ACCEPT_SIZE;
}
// The accept extension, if any, is written straight after the mac
size_t ext_len = this->ctx_.resume_cache().try_accept(
this->rx_buf_.data(), this->rx_buf_.size(), this->prologue_.data(), this->prologue_.size(), msg + total_size,
sizeof(msg) - total_size, send_cipher_, recv_cipher_);
bool resume = ext_len != 0;
total_size += ext_len;
APIError aerr = write_frame_(msg, total_size);
if (aerr != APIError::OK)
+43 -36
View File
@@ -8,13 +8,20 @@
namespace esphome::noise {
static constexpr char LABEL_OFFER[] = "offer";
static constexpr char LABEL_CONFIRM[] = "confirm";
static constexpr char LABEL_KEYS[] = "keys";
// Largest KDF input: "keys" || client_nonce || server_nonce || SHA256(prologue)
static constexpr size_t RESUME_KDF_MAX_DATA = sizeof(LABEL_KEYS) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32;
static_assert(sizeof(LABEL_CONFIRM) - 1 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE <= RESUME_KDF_MAX_DATA,
"MAC input must fit the KDF buffer");
/// Noise-construction HKDF-SHA256 keyed with the ticket secret over
/// label || a || b [|| SHA256(hash_in)]. out2 == nullptr means MAC only.
static bool resume_kdf(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, const uint8_t *hash_in, size_t hash_in_len, uint8_t *out1,
size_t out1_len, uint8_t *out2) {
// largest use is "confirm"(7) + 16 + 16, or "keys"(4) + 16 + 16 + 32
uint8_t data[7 + RESUME_NONCE_SIZE + RESUME_NONCE_SIZE + 32];
uint8_t data[RESUME_KDF_MAX_DATA];
uint8_t scratch[32];
size_t len = label_len + a_len + b_len;
std::memcpy(data, label, label_len); // NOLINT(bugprone-not-null-terminated-result)
@@ -50,45 +57,45 @@ bool ResumeTicketCache::issue(ResumeTicket &out) {
return true;
}
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;
size_t ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue,
size_t prologue_len, uint8_t *out_ext, size_t out_capacity,
NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher) {
if (offer_len != RESUME_OFFER_SIZE || offer[0] != RESUME_OFFER_VERSION || out_capacity < RESUME_ACCEPT_SIZE) {
return 0;
}
const uint8_t *session_id = offer + RESUME_OFFER_SESSION_ID_OFFSET;
const uint8_t *client_nonce = offer + RESUME_OFFER_NONCE_OFFSET;
uint8_t secret[RESUME_SECRET_SIZE];
bool ok = false;
for (uint8_t i = 0; i < SLOTS && !ok; i++) {
ResumeTicket &slot = this->slots_[i];
if (!(this->used_mask_ & (1u << i)) || std::memcmp(slot.session_id, session_id, RESUME_SESSION_ID_SIZE) != 0) {
continue;
ResumeTicket *ticket = nullptr;
for (uint8_t i = 0; i < SLOTS; i++) {
if ((this->used_mask_ & (1u << i)) &&
std::memcmp(this->slots_[i].session_id, session_id, RESUME_SESSION_ID_SIZE) == 0) {
ticket = &this->slots_[i];
this->used_mask_ &= static_cast<uint8_t>(~(1u << i));
break;
}
uint8_t expected[RESUME_MAC_SIZE];
ok = resume_compute_offer_mac(slot.secret, session_id, client_nonce, expected) &&
noise_is_equal(expected, offer + RESUME_OFFER_MAC_OFFSET, 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, slot.secret, RESUME_SECRET_SIZE);
noise_clean(&slot, sizeof(slot));
this->used_mask_ &= static_cast<uint8_t>(~(1u << i));
}
if (ticket == nullptr) {
return 0;
}
uint8_t expected[RESUME_MAC_SIZE];
bool ok = resume_compute_offer_mac(ticket->secret, session_id, client_nonce, expected) &&
noise_is_equal(expected, offer + RESUME_OFFER_MAC_OFFSET, RESUME_MAC_SIZE);
noise_clean(expected, sizeof(expected));
if (!ok) {
return false;
// Bad MAC: keep the ticket so a forger cannot burn it
this->used_mask_ |= static_cast<uint8_t>(1u << static_cast<uint8_t>(ticket - this->slots_));
return 0;
}
// From here every failure still falls back to the full handshake; the
// ticket is spent, which is harmless (the client gets a fresh one).
// The ticket is spent from here; any later failure falls back to the full
// handshake and the client gets a fresh one.
uint8_t *server_nonce = out_ext + 1;
uint8_t k_c2d[32];
uint8_t k_d2c[32];
out_ext[0] = RESUME_ACCEPT_VERSION;
ok = random_bytes(server_nonce, RESUME_NONCE_SIZE) &&
resume_compute_confirm_mac(secret, client_nonce, server_nonce, out_ext + 1 + RESUME_NONCE_SIZE) &&
resume_derive_keys(secret, client_nonce, server_nonce, prologue, prologue_len, k_c2d, k_d2c);
noise_clean(secret, sizeof(secret));
resume_compute_confirm_mac(ticket->secret, client_nonce, server_nonce, out_ext + 1 + RESUME_NONCE_SIZE) &&
resume_derive_keys(ticket->secret, client_nonce, server_nonce, prologue, prologue_len, k_c2d, k_d2c);
noise_clean(ticket, sizeof(*ticket));
if (ok) {
recv_cipher = resume_make_cipher(k_c2d);
send_cipher = resume_make_cipher(k_d2c);
@@ -102,7 +109,7 @@ bool ResumeTicketCache::try_accept(const uint8_t *offer, size_t offer_len, const
}
noise_clean(k_c2d, sizeof(k_c2d));
noise_clean(k_d2c, sizeof(k_d2c));
return ok;
return ok ? RESUME_ACCEPT_SIZE : 0;
}
void ResumeTicketCache::clear() {
@@ -112,20 +119,20 @@ 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_kdf(secret, "offer", 5, session_id, RESUME_SESSION_ID_SIZE, client_nonce, RESUME_NONCE_SIZE, nullptr, 0,
out_mac, RESUME_MAC_SIZE, nullptr);
return resume_kdf(secret, LABEL_OFFER, sizeof(LABEL_OFFER) - 1, session_id, RESUME_SESSION_ID_SIZE, client_nonce,
RESUME_NONCE_SIZE, nullptr, 0, out_mac, RESUME_MAC_SIZE, nullptr);
}
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_kdf(secret, "confirm", 7, client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, nullptr, 0,
out_mac, RESUME_MAC_SIZE, nullptr);
return resume_kdf(secret, LABEL_CONFIRM, sizeof(LABEL_CONFIRM) - 1, client_nonce, RESUME_NONCE_SIZE, server_nonce,
RESUME_NONCE_SIZE, nullptr, 0, out_mac, RESUME_MAC_SIZE, nullptr);
}
bool resume_derive_keys(const uint8_t *secret, const uint8_t *client_nonce, const uint8_t *server_nonce,
const uint8_t *prologue, size_t prologue_len, uint8_t *k_c2d, uint8_t *k_d2c) {
return resume_kdf(secret, "keys", 4, client_nonce, RESUME_NONCE_SIZE, server_nonce, RESUME_NONCE_SIZE, prologue,
prologue_len, k_c2d, 32, k_d2c);
return resume_kdf(secret, LABEL_KEYS, sizeof(LABEL_KEYS) - 1, client_nonce, RESUME_NONCE_SIZE, server_nonce,
RESUME_NONCE_SIZE, prologue, prologue_len, k_c2d, 32, k_d2c);
}
NoiseCipherState *resume_make_cipher(const uint8_t *key) {
+7 -6
View File
@@ -57,12 +57,13 @@ class ResumeTicketCache {
/// slot. Returns false (and stores nothing) if the RNG fails.
bool issue(ResumeTicket &out);
/// Accept a resume offer: verify and consume the ticket (single use; a
/// forged MAC never burns one), build both transport ciphers, and fill
/// the RESUME_ACCEPT_SIZE ServerHello extension. Returns false with
/// nothing allocated on any miss or failure. 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).
/// forged MAC never burns one), build both transport ciphers, and write
/// the ServerHello accept extension into out_ext. Returns the extension
/// length, or 0 (nothing allocated) on any miss, failure, or when
/// out_capacity is too small. Secrets are wiped internally.
size_t try_accept(const uint8_t *offer, size_t offer_len, const uint8_t *prologue, size_t prologue_len,
uint8_t *out_ext, size_t out_capacity, NoiseCipherState *&send_cipher,
NoiseCipherState *&recv_cipher); /// Forget every ticket (PSK change).
void clear();
// Realistically one or two controllers hold a ticket at a time; a third
+11 -4
View File
@@ -2518,6 +2518,11 @@ def calculate_message_max_size(desc: descriptor.DescriptorProto) -> int | None:
return total_size
# Contents must never reach the log: dump_to prints only the name
SENSITIVE_MESSAGES = {"NoiseResumeTicket"}
SENSITIVE_MESSAGES_SEEN: set[str] = set()
def build_message_type(
desc: descriptor.DescriptorProto,
base_class_fields: dict[str, list[descriptor.FieldDescriptorProto]],
@@ -2799,9 +2804,9 @@ def build_message_type(
public_content.append(prot)
# If no fields to calculate size for or message doesn't need encoding, the default implementation in ProtoMessage will be used
# Sensitive messages (keys, tickets) dump only their name
if desc.name in SENSITIVE_MESSAGES:
dump = []
SENSITIVE_MESSAGES_SEEN.add(desc.name)
# dump_to method declaration in header
prot = "#ifdef HAS_PROTO_MESSAGE_DUMP\n"
@@ -2849,9 +2854,6 @@ def build_message_type(
return out, cpp, dump_cpp
# Messages whose contents are secret; dump_to prints only the name
SENSITIVE_MESSAGES = {"NoiseResumeTicket"}
SOURCE_BOTH = 0
SOURCE_SERVER = 1
SOURCE_CLIENT = 2
@@ -3709,6 +3711,11 @@ static const char *const TAG = "api.service";
except ImportError:
pass
# A renamed message must fail the build, not silently start dumping secrets
missing = SENSITIVE_MESSAGES - SENSITIVE_MESSAGES_SEEN
if missing:
raise RuntimeError(f"SENSITIVE_MESSAGES not found in api.proto: {missing}")
if __name__ == "__main__":
sys.exit(main())
+29 -19
View File
@@ -38,6 +38,15 @@ static void build_offer(uint8_t *offer, const uint8_t *session_id, const uint8_t
std::memcpy(offer + RESUME_OFFER_MAC_OFFSET, mac, RESUME_MAC_SIZE);
}
/// "NoiseAPIInit" || be16(len) || offer, exactly as the api frame helper mixes it
static constexpr size_t KAT_PROLOGUE_SIZE = 12 + 2 + RESUME_OFFER_SIZE;
static void build_prologue(uint8_t *out, const uint8_t *offer) {
std::memcpy(out, "NoiseAPIInit", 12); // NOLINT(bugprone-not-null-terminated-result)
out[12] = 0x00;
out[13] = RESUME_OFFER_SIZE;
std::memcpy(out + 14, offer, RESUME_OFFER_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));
@@ -68,13 +77,12 @@ TEST(NoiseResumeKat, OfferMacMatchesClientImplementation) {
TEST(NoiseResumeKat, KeyDerivationMatchesClientImplementation) {
// Prologue used by the shared vectors: "NoiseAPIInit" + be16(41) + a
// 41-byte offer whose MAC field is 16 bytes of 0xEE
uint8_t prologue[14 + RESUME_OFFER_SIZE];
std::memcpy(prologue, "NoiseAPIInit", 12);
prologue[12] = 0x00;
prologue[13] = RESUME_OFFER_SIZE;
uint8_t mac_filler[RESUME_MAC_SIZE];
std::memset(mac_filler, 0xEE, sizeof(mac_filler));
build_offer(prologue + 14, KAT_SESSION_ID, KAT_CLIENT_NONCE, mac_filler);
uint8_t offer[RESUME_OFFER_SIZE];
build_offer(offer, KAT_SESSION_ID, KAT_CLIENT_NONCE, mac_filler);
uint8_t prologue[KAT_PROLOGUE_SIZE];
build_prologue(prologue, offer);
uint8_t k_c2d[32], k_d2c[32];
ASSERT_TRUE(
@@ -89,15 +97,13 @@ TEST(NoiseResumeCache, TryAcceptConsumesTicketOnceAndProvesPossession) {
uint8_t offer[RESUME_OFFER_SIZE];
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 prologue[KAT_PROLOGUE_SIZE];
build_prologue(prologue, offer);
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_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv),
RESUME_ACCEPT_SIZE);
ASSERT_NE(send, nullptr);
ASSERT_NE(recv, nullptr);
@@ -126,7 +132,7 @@ TEST(NoiseResumeCache, TryAcceptConsumesTicketOnceAndProvesPossession) {
// Single use: the same offer must miss the second time
NoiseCipherState *send2 = nullptr, *recv2 = nullptr;
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send2, recv2));
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send2, recv2), 0u);
EXPECT_EQ(send2, nullptr);
EXPECT_EQ(recv2, nullptr);
}
@@ -145,15 +151,18 @@ TEST(NoiseResumeCache, BadMacOrMalformedOfferLeavesTicketIntact) {
uint8_t ext[RESUME_ACCEPT_SIZE];
NoiseCipherState *send = nullptr, *recv = nullptr;
// A forged offer must not burn the ticket
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u);
// 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));
EXPECT_EQ(cache.try_accept(offer, sizeof(offer) - 1, prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u);
offer[0] = 0x7f;
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u);
offer[0] = RESUME_OFFER_VERSION;
// No room for the extension must also decline without burning it
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext) - 1, send, recv), 0u);
// The genuine offer still redeems
EXPECT_TRUE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv),
RESUME_ACCEPT_SIZE);
noise_cipherstate_free(send);
noise_cipherstate_free(recv);
}
@@ -171,11 +180,12 @@ TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) {
// The oldest ticket was evicted by the one-past-capacity issue
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));
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u);
// The rest remain redeemable
for (int i = 1; i <= ResumeTicketCache::SLOTS; i++) {
build_offer_for_ticket(offer, tickets[i], KAT_CLIENT_NONCE);
EXPECT_TRUE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv),
RESUME_ACCEPT_SIZE);
noise_cipherstate_free(send);
noise_cipherstate_free(recv);
send = recv = nullptr;
@@ -186,7 +196,7 @@ TEST(NoiseResumeCache, IssueRotatesSlotsAndClearForgetsAll) {
ASSERT_TRUE(cache.issue(ticket));
cache.clear();
build_offer_for_ticket(offer, ticket, KAT_CLIENT_NONCE);
EXPECT_FALSE(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, send, recv));
EXPECT_EQ(cache.try_accept(offer, sizeof(offer), prologue, sizeof(prologue), ext, sizeof(ext), send, recv), 0u);
}
} // namespace esphome::noise::testing