Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
key_extraction_responder.cpp
Go to the documentation of this file.
2
3#include "log_helpers.h"
4
5#include "device_registry.h"
6#include "pairing_responder.h"
7#include "proto_commands.h"
8#include "proto_crypto.h"
9#include "radio_interface.h"
10#include "tuning_config.h"
11
12#include <esp_random.h>
13
14#include <cinttypes>
15#include <cstdio>
16#include <cstring>
17#include <string>
18
19/// @file key_extraction_responder.cpp
20/// @brief "Recover System Key" (key extraction) — device-role responder collaborator.
21/// @ingroup hioc_hub
22///
23/// Owns the impure side of the key-extraction feature: arming/disarming, throwaway node-ID
24/// generation, the 10-minute auto-off timer, the post-extraction grace window, transmitting
25/// device-role replies, and the security-sensitive result log block. The pure state-transition
26/// decisions live in pairing_responder.h/.cpp; the six RX branches
27/// (0x28/0x2C/0x31/0x32/0x36/0x3C) dispatch through try_handle_frame(), called from
28/// process_received_packet_() (hub_status.cpp).
29///
30/// @note Hardware-confirmed 2026-08-02: a full extraction (0x28 through 0x33) between two real
31/// boards — SX1276 running this responder, SX1262 running this project's own PairingEngine as
32/// the "hub" — recovered the hub's node_id/system_key byte-for-byte. That validates the crypto,
33/// the state machine, and the radio wiring end-to-end on real RF hardware.
34/// @warning What that test does NOT validate: compatibility with a genuine third-party hub
35/// (Somfy TaHoma/Smoove, Velux KLF200, etc.). The device-role frames built here
36/// (create_discover_resp(), create_challenge_req(), create_key_confirm()) were reverse-engineered
37/// from this project's own encoder and a small number of captures. The self-test above
38/// necessarily agrees with those conventions (it's the same codebase on both ends); a real hub's
39/// exact requirements (discovery-response field completeness, retry cadence) may still differ. It
40/// is blind to two things in particular: a device-role frame that is self-consistent but wrong on
41/// air, and a protocol step a real hub requires that this project's own controller role never
42/// sends. Both are real failure modes against real hubs; see
43/// tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_stall.yaml and
44/// tests/corpus/captures/pairing/somfy_connectivity_kit_pairing_key_extraction_stall.yaml.
45///
46/// The device-role builders (create_discover_resp(), create_challenge_req_device_role(),
47/// create_key_confirm(), create_discover_confirm_ack()) are each pinned against a real device's
48/// captured framing by tests/corpus/corpus_device_role_builder_test.cpp, including
49/// create_discover_resp()'s flags/timestamp bytes, which mirror a real Somfy Izymo dimmer's
50/// captured values (see KEY_EXTRACTION_DISCOVER_RESP_FLAGS/_TIMESTAMP in proto_constants.h/proto_commands.cpp for
51/// the derivation) but remain unconfirmed against a real hub like every device-role field here.
52///
53/// recover_system_key_from_transfer()'s IV-derivation formula itself is independently pinned
54/// against two externally-captured known-answer key transfers
55/// (ProtoCrypto.CryptKeyMatchesDocumented*Capture in proto_crypto_test.cpp), so that formula does
56/// not rest on this codebase's own conventions — though both captures are short requests and don't
57/// exercise construct_iv()'s 8-byte truncation window, so a real hub sending a longer request is an
58/// open question. Treat a recovered key as unconfirmed until it has been verified against a real
59/// hub, or by successfully controlling a device with it.
60
61namespace esphome {
62namespace home_io_control {
63
64namespace {
65
66constexpr uint32_t KEY_EXTRACTION_AUTO_OFF_MS = 10 * 60 * 1000; ///< Arm window: 10 minutes.
67// TODO(hardware-verify): confirm a real hub's pairing flow doesn't validate the advertised
68// manufacturer/type against a known-device allowlist before completing key exchange —
69// Somfy/roller-shutter is a plausible but unconfirmed default.
70constexpr uint8_t KEY_EXTRACTION_MANUFACTURER_ID = MANUFACTURER_SOMFY; ///< Plausible, widely-supported default.
71constexpr uint8_t KEY_EXTRACTION_ID_GEN_MAX_ATTEMPTS = 16; ///< Collision-retry budget for the throwaway node ID.
72constexpr size_t IO_CHANNEL_COUNT = 3; ///< FREQ_CH1..FREQ_CH3.
73constexpr const char *KEY_EXTRACTION_DISCOVER_REPLY_TIMER_NAME = "key_extraction_discover_reply";
74constexpr const char *KEY_EXTRACTION_TIMEOUT_NAME = "key_extraction_auto_off";
75constexpr uint32_t RANDOM_LOW_BYTE_MASK = 0xFF; ///< Isolates one random byte from esp_random()'s 32-bit output.
76
77constexpr uint32_t KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS = 5000; ///< 5 seconds.
78// Bounds the responder listen (key_extraction_responder.h's key_extraction_hold_deadline_ms_ /
79// awaiting_reply()) for the 3 pre-extraction states (SENT_DISCOVER_RESP, SENT_CONFIRM_ACK,
80// SENT_CHALLENGE), none of which has a hold bound of its own otherwise -- CMD_DISCOVER_REQ is
81// handled before the throwaway-ID dst filter (try_handle_frame()), so *any* 0x28 from any hub in
82// range that then goes silent would otherwise keep the radio listening for the rest of the arm
83// window, with background polls deferred.
84//
85// This is purely a radio-scheduling optimization, not a protocol-recovery deadline: expiry only
86// stops loop() from listening for this responder (see key_extraction_hold_deadline_ms_'s doc
87// comment in key_extraction_responder.h) -- it does NOT touch key_extraction_ctx_.state, so a real
88// hub's frame arriving even slightly late is still accepted by the pure guards in
89// pairing_responder.cpp exactly as if the listen were still active, just caught by the coarser
90// idle hop instead. That makes the cost of sizing this too small merely "occasionally falls back
91// to the idle hop a little early," not "silently drops a live attempt" -- so 5s is sized
92// generously rather than tightly: a real hub that's still trying should have its very next frame
93// land well inside this window -- comfortably above a single retry gap (EXCHANGE_RETRY_DELAY_MS=250ms)
94// plus the request/response windows either side of it
95// (PAIRING_KEY_CHALLENGE_TIMEOUT_MS/PAIRING_KEY_CONFIRM_TIMEOUT_MS=500ms each, pairing_engine.h) --
96// while staying "a few seconds", not minutes, sized against third-party hub timing (the whole
97// reason this feature exists), not this project's own controller role. Not hardware-measured;
98// deliberately generous rather than tight, matching KEY_EXTRACTION_POST_EXTRACT_GRACE_MS's own
99// reasoning below.
100
101constexpr uint32_t KEY_EXTRACTION_POST_EXTRACT_GRACE_MS = 60000; ///< One minute.
102// TODO(hardware-verify): no timing data exists for the 0x33->0x36 gap on real hardware (the only
103// capture of that gap is an untimed SPI trace). One minute is deliberately generous rather than
104// tight: every pre-EXTRACTED guard in pairing_responder.cpp still rejects EXTRACTED/SENT_ADDRESS_
105// RESP for 0x2C/0x31/0x32, so a *different* hub cannot interfere with a live round inside this
106// window. on_discover_request() is the one exception, deliberately: it accepts a fresh 0x28 from
107// the *same* hub as ctx.hub_node_id (see that function's doxygen for why), so the window is inert
108// to every hub except the one it's actually running a round with. It is still hard-capped by the
109// 10-minute auto-off timer above, and the only cost of overshooting is that the HA switch reports
110// "still listening" for longer. Undershooting, by contrast, silently drops a slow hub's
111// node-verification round — the exact failure this feature exists to fix. Not measured.
112constexpr const char *KEY_EXTRACTION_GRACE_TIMER_NAME = "key_extraction_post_extract_grace";
113
114} // namespace
115
116namespace detail {
117
118std::string build_key_extraction_report(const uint8_t node_id[NODE_ID_SIZE], const uint8_t key[AES_KEY_SIZE]) {
119 return "SYSTEM KEY EXTRACTED -- DO NOT SHARE YOUR SYSTEM KEY\n"
120 "Anyone with this key and node_id can control every device on this installation.\n"
121 "This exchange has not been independently confirmed against your specific hub -- test\n"
122 "this key (e.g. by controlling a device with it) before relying on it.\n"
123 "Copy the block below into a new hub's YAML.\n"
124 "home_io_control:\n"
125 " node_id: \"" +
126 node_id_to_string(node_id) + "\"\n" + " system_key: \"" + format_key_hex(key) + "\"";
127}
128
129} // namespace detail
130
131KeyExtractionResponder::KeyExtractionResponder(const uint8_t *node_id, RadioDriver **radio, const TuningConfig *tuning,
132 DeviceRegistry &registry, TransmitFrameFn transmit,
133 NamedTimeoutFn schedule_auto_off)
134 : node_id_(node_id),
135 radio_(radio),
136 tuning_(tuning),
137 registry_(registry),
138 transmit_(std::move(transmit)),
139 schedule_auto_off_(std::move(schedule_auto_off)) {}
140
141void KeyExtractionResponder::generate_throwaway_id(uint8_t out[NODE_ID_SIZE]) {
142 for (uint8_t attempt = 0; attempt < KEY_EXTRACTION_ID_GEN_MAX_ATTEMPTS; attempt++) {
143 for (uint8_t i = 0; i < NODE_ID_SIZE; i++)
144 out[i] = static_cast<uint8_t>(esp_random() & RANDOM_LOW_BYTE_MASK);
145 if (!stored_node_id_is_valid(out))
146 continue;
147 if (memcmp(out, this->node_id_, NODE_ID_SIZE) == 0)
148 continue;
149 if (memcmp(out, BROADCAST_DISCOVER, NODE_ID_SIZE) == 0 || memcmp(out, BROADCAST_DISCOVER_ALT, NODE_ID_SIZE) == 0)
150 continue;
151 if (this->registry_.get(node_id_to_string(out)) != nullptr)
152 continue;
153 return;
154 }
155 // Every attempt collided (astronomically unlikely for a 3-byte space against a handful of
156 // reserved/registered IDs) — fall through and use the last-generated candidate rather than
157 // leaving the buffer stale; a false collision here only degrades to "discovery/key-init from
158 // the colliding real device also gets intercepted," not a crash or security issue.
159}
160
162 if (!armed) {
164 return;
166 ESP_LOGI(detail::TAG, "Key extraction: disarmed");
167 if (this->armed_callback_)
168 this->armed_callback_(false);
169 return;
170 }
171
174 this->set_configured_type_();
176
177 ESP_LOGW(detail::TAG,
178 "Key extraction: ARMED for 10 minutes, throwaway ID %s. Put your existing hub into pairing/add-device "
179 "mode now.",
181
182 // This timer and arm_post_extraction_grace()'s below share one idiom (named set_timeout(),
183 // guarded by a state check so a stale callback from a disarm-and-rearm inside the window can't
184 // act on the wrong cycle, logging, then disarming) — two call sites, not enough to be worth
185 // extracting into a shared helper at the cost of an extra layer of indirection between the
186 // guard condition and what it's guarding.
187 this->schedule_auto_off_(KEY_EXTRACTION_TIMEOUT_NAME, KEY_EXTRACTION_AUTO_OFF_MS, [this]() {
188 // Guards against a stale timeout firing after a manual disarm/re-arm already ran; this hub's
189 // set_timeout() replaces any pending callback with the same name, but the check is cheap
190 // insurance and documents the intent either way.
192 return;
194 ESP_LOGW(detail::TAG, "Key extraction: window expired, no pairing attempt seen. Disarming.");
195 } else {
196 ESP_LOGW(detail::TAG, "Key extraction: window expired while in progress (reached stage=%s). Disarming.",
198 }
199 this->set_armed(false);
200 });
201
202 if (this->armed_callback_)
203 this->armed_callback_(true);
204}
205
206bool KeyExtractionResponder::try_handle_frame(const IoFrame &frame, uint32_t rx_freq_hz) {
208 return false;
209 // Kept for the handler that answers this frame: answering makes this channel the session's
210 // (send_reply_()). An unanswered frame leaves the session channel as it was.
211 this->rx_freq_hz_ = rx_freq_hz != 0 ? rx_freq_hz : FREQ_CH2;
212 this->rx_ms_ = millis();
213
214 if (frame.cmd == CMD_DISCOVER_REQ) {
215 this->handle_discover_(frame);
216 return true;
217 }
218 if (memcmp(frame.dst, this->key_extraction_ctx_.throwaway_id, NODE_ID_SIZE) != 0)
219 return false;
220 if (frame.cmd == CMD_DISCOVER_CONFIRM) {
221 this->handle_discover_confirm_(frame);
222 return true;
223 }
224 if (frame.cmd == CMD_KEY_INIT) {
225 this->handle_key_init_(frame);
226 return true;
227 }
228 if (frame.cmd == CMD_KEY_TRANSFER) {
229 this->handle_key_transfer_(frame);
230 return true;
231 }
232 if (frame.cmd == CMD_NODE_VERIFY_REQ) {
233 this->handle_node_verify_req_(frame);
234 return true;
235 }
236 if (frame.cmd == CMD_CHALLENGE_REQ) {
237 this->handle_node_verify_challenge_(frame);
238 return true;
239 }
240 return false;
241}
242
243void KeyExtractionResponder::send_reply_(const IoFrame &frame) {
244 // The frame being answered opens (or continues) the session on its channel: the peer sent it
245 // there and waits there for our answer, and its in-session follow-up comes on the same channel.
246 this->session_freq_ = this->rx_freq_hz_;
247 this->session_rx_ms_ = this->rx_ms_;
248
249 // Every reply that reaches this path (0x2D, 0x3C, 0x33, 0x37, 0x3D) is `start=false`: it lands
250 // on the channel the peer holds, so it keeps the driver's chip-tuned response_preamble(), same
251 // as every other in-exchange reply in this codebase. The one start-flagged reply, the 0x29, goes
252 // out through schedule_discover_reply_() instead.
253 //
254 // Do NOT widen this to a flat LONG_PREAMBLE(1024) for every reply: hardware-confirmed
255 // 2026-08-02, that blocked the main loop long enough to blow through the hub's tight per-try
256 // wait windows and broke both directions.
257 const uint16_t preamble = (*this->radio_)->response_preamble();
258
259 // An answer to a frame addressed to us goes out once, on that frame's channel, at once: the
260 // peer stays on the channel it sent on to wait for it (0 of 300 measured unicast replies came
261 // back on another channel, exchange_engine.cpp's first-response wait). Repeating it on the
262 // other channels would only keep this radio transmitting while the peer already answers.
263 this->transmit_(frame, this->session_freq_, preamble);
264}
265
266void KeyExtractionResponder::schedule_discover_reply_(const IoFrame &search, const IoFrame &resp) {
267 // A device answers a search once, in a random slot from 1 to N (never slot 0), on one
268 // channel, and the searcher collects for the whole window before it confirms. The answer is
269 // therefore a timed transmission, not a reply at once: the searcher's window is N slots long.
270 const uint32_t slots = discovery_slot_count(search.dst);
271 const uint32_t slot = 1 + (esp_random() % slots);
272 const uint32_t delay_ms = slot * DISCOVERY_SLOT_MS;
273 // One random channel of the two the search did not come in on: a searcher listens on those for
274 // its answers (a discovery reply lands on the request channel in about 1 of 149 measured
275 // answers, ListenPolicy::ROTATE_SKIPPING_REQUEST), so a draw over all three would lose a third.
276 static constexpr uint32_t CHANNELS[IO_CHANNEL_COUNT] = {FREQ_CH1, FREQ_CH2, FREQ_CH3};
277 size_t search_index = 1; // CH2, the channel a frame with no reported channel counts as
278 for (size_t i = 0; i < IO_CHANNEL_COUNT; i++) {
279 if (CHANNELS[i] == this->rx_freq_hz_)
280 search_index = i;
281 }
282 // One or two channels on from the search's: always one of the other two.
283 const size_t step = 1 + (esp_random() % (IO_CHANNEL_COUNT - 1));
284 const uint32_t freq = CHANNELS[(search_index + step) % IO_CHANNEL_COUNT];
285
286 // The 0x29 is no reply on the search's channel: the session (and its channel hold) opens only
287 // with the confirm that follows, which may come on any channel. Leave the hold expired.
288 this->session_freq_ = this->rx_freq_hz_;
289 this->session_rx_ms_ = this->rx_ms_ - this->session_window_ms_;
290
291 this->pending_discover_resp_ = resp;
292 this->discover_reply_pending_ = true;
293 this->discover_reply_due_ms_ = millis() + delay_ms;
294 // Same timer name: a repeated search replaces the answer still waiting for its slot.
295 this->schedule_auto_off_(KEY_EXTRACTION_DISCOVER_REPLY_TIMER_NAME, delay_ms, [this, freq]() {
296 this->discover_reply_pending_ = false;
297 // Only the attempt that scheduled it may send it: after a disarm, or a disarm and re-arm (a
298 // fresh throwaway ID, state back to ARMED_IDLE), this answer is stale.
300 return;
301 // The one reply a hopping/scanning peer has to catch cold, so `cold_broadcast_reply_preamble`:
302 // long enough for that, much shorter than LONG_PREAMBLE so the loop is not blocked.
303 this->transmit_(this->pending_discover_resp_, freq, this->tuning_->cold_broadcast_reply_preamble);
304 });
305 // Hold the radio (and defer background polls) through the slot and the confirm that follows it.
306 this->key_extraction_hold_deadline_ms_ = millis() + delay_ms + KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS;
307 ESP_LOGD(detail::TAG,
308 "Key extraction: discovery answer in slot %" PRIu32 " of %" PRIu32 " (%" PRIu32 " ms) on %" PRIu32 " Hz",
309 slot, slots, delay_ms, freq);
310}
311
312void KeyExtractionResponder::set_configured_type_() {
313 // The word is the wire form (type << 6 | subtype), so a value copied from a real device's 0x29 is
314 // sent back byte for byte.
315 const uint16_t word = this->tuning_->key_extraction_type_word;
316 const auto msb = static_cast<uint8_t>(word >> BITS_PER_BYTE);
317 const auto lsb = static_cast<uint8_t>(word); // low byte: top 2 type bits + 6-bit subtype
320}
321
322void KeyExtractionResponder::handle_discover_(const IoFrame &frame) {
324 return;
325
326 // A controller that searches for one device type (a typed discovery such as `00 FF FB`, sent by an
327 // Atlantic heater in its connectivity menu, #199) is expected to ignore answers of any other type, so
328 // answer as the type it asked for. Decided per request, not per attempt: a hub may send a typed and an open discovery
329 // in sequence. Without a typed request (an open `00 00 3B` discovery, or an undefined type) the configured
330 // `key_extraction_type_word` applies. The manufacturer stays Somfy: one variable at a time.
331 const DeviceType requested_type = discovery_target_type(frame.dst);
332 if (requested_type != DeviceType::UNKNOWN) {
333 this->key_extraction_ctx_.advertised_type = requested_type;
335 } else {
336 this->set_configured_type_();
337 }
338
339 IoFrame resp;
340 // The Multi Information Byte is a tuning choice (key_extraction_multi_info): a hub may skip a
341 // device that advertises low power (#215). The session window follows its turnaround class.
342 const uint8_t flags = this->tuning_->key_extraction_multi_info;
343 this->session_window_ms_ = key_extraction_session_window_ms(flags);
344 if (!create_discover_resp(resp, this->key_extraction_ctx_.throwaway_id, frame.src,
345 this->key_extraction_ctx_.advertised_type, this->key_extraction_ctx_.advertised_subtype,
346 KEY_EXTRACTION_MANUFACTURER_ID, flags)) {
347 ESP_LOGW(detail::TAG, "Key extraction: failed to build discovery response");
348 // Deliberately does NOT touch key_extraction_hold_deadline_ms_: on_discover_request() already
349 // advanced ctx.state above, but a failed builder means no reply went out, so there is nothing
350 // for the hub to be replying to yet. Leaving the deadline exactly as it was (0 on a fresh arm,
351 // safely "already expired" per key_extraction_hold_deadline_ms_'s doc comment; or whatever an
352 // earlier successful reply set it to, still correctly bounded) is what keeps a builder failure
353 // from either holding the radio unboundedly or clobbering a still-valid earlier deadline.
354 return;
355 }
356 this->schedule_discover_reply_(frame, resp);
357 ESP_LOGI(detail::TAG,
358 "Key extraction: answering discovery from hub %s as node type %u subtype %u (type word 0x%02X%02X, "
359 "multi info 0x%02X) with throwaway ID %s",
360 node_id_to_string(frame.src).c_str(), static_cast<unsigned>(this->key_extraction_ctx_.advertised_type),
361 this->key_extraction_ctx_.advertised_subtype, resp.data[0], resp.data[1], flags,
363}
364
365void KeyExtractionResponder::handle_discover_confirm_(const IoFrame &frame) {
367 return;
368
369 IoFrame resp;
370 if (!create_discover_confirm_ack(resp, this->key_extraction_ctx_.throwaway_id, frame.src)) {
371 ESP_LOGW(detail::TAG, "Key extraction: failed to build discovery-confirm ack");
372 // See handle_discover_()'s matching comment: leaving the hold deadline untouched on a builder
373 // failure is deliberate, not an oversight.
374 return;
375 }
376 this->send_reply_(resp);
377 this->key_extraction_hold_deadline_ms_ = millis() + KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS;
378 ESP_LOGI(detail::TAG, "Key extraction: acknowledged discovery confirm from hub %s",
379 node_id_to_string(frame.src).c_str());
380}
381
382void KeyExtractionResponder::handle_key_init_(const IoFrame &frame) {
383 uint8_t candidate_challenge[HMAC_SIZE];
384 crypto::generate_challenge(candidate_challenge);
385 if (!pairing_responder::on_key_init(this->key_extraction_ctx_, candidate_challenge, frame.src))
386 return;
387
388 IoFrame resp;
389 if (!create_challenge_req_device_role(resp, frame.src, this->key_extraction_ctx_.throwaway_id,
390 this->key_extraction_ctx_.challenge)) {
391 ESP_LOGW(detail::TAG, "Key extraction: failed to build challenge request");
392 // See handle_discover_()'s matching comment: leaving the hold deadline untouched on a builder
393 // failure is deliberate, not an oversight.
394 return;
395 }
396 this->send_reply_(resp);
397 this->key_extraction_hold_deadline_ms_ = millis() + KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS;
398 ESP_LOGI(detail::TAG, "Key extraction: sent challenge to hub %s", node_id_to_string(frame.src).c_str());
399}
400
401void KeyExtractionResponder::handle_key_transfer_(const IoFrame &frame) {
402 if (frame.data_len < AES_KEY_SIZE) {
403 ESP_LOGW(detail::TAG, "Key extraction: key-transfer payload too short (%u bytes)", frame.data_len);
404 return;
405 }
407 return;
408
409 IoFrame resp;
410 if (create_key_confirm(resp, this->key_extraction_ctx_.throwaway_id, frame.src)) {
411 this->send_reply_(resp);
412 } else {
413 ESP_LOGW(detail::TAG, "Key extraction: failed to build key confirm");
414 }
415
416 // Log before the grace window can disarm us: disarm resets key_extraction_ctx_, which is where
417 // the recovered key and the hub's real node ID live.
418 this->log_result_();
419 ESP_LOGI(detail::TAG,
420 "Key extraction: still listening for up to %" PRIu32
421 " more seconds in case the hub verifies this device (CMD_NODE_VERIFY_REQ/0x36) — leave the "
422 "switch on until it turns off on its own.",
423 KEY_EXTRACTION_POST_EXTRACT_GRACE_MS / 1000);
424 // Don't disarm immediately: some hubs (Velux KLR200) follow the key exchange with a node
425 // verification request (0x36) and a challenge (0x3C) against our answer, and disarming here would make the
426 // responder deaf to that round before it can happen. A *different* hub attempting to pair
427 // mid-window still cannot succeed and produce a second, confusing log block — every pure guard in
428 // pairing_responder.cpp except on_discover_request() unconditionally rejects EXTRACTED/
429 // SENT_NODE_VERIFY_RESP, and on_discover_request() itself only accepts a fresh 0x28 from that same
430 // hub_node_id, so a different hub's traffic still cannot advance the state machine backwards. The
431 // grace timer below disarms once no further progress is seen from the real hub, instead of doing
432 // it at once.
434}
435
436void KeyExtractionResponder::handle_node_verify_req_(const IoFrame &frame) {
437 // Our throwaway ID is not a secret -- it went out in clear in our own 0x29/0x37 -- so the dst
438 // check in try_handle_frame() alone doesn't establish this frame actually came from the hub we
439 // exchanged keys with. hub_node_id was captured from the 0x31 that started this attempt
440 // (pairing_responder::on_key_init()); anything else claiming our throwaway ID as dst is not that
441 // hub and gets no reply, closing an otherwise-unbounded loop an onlooker could drive to keep
442 // re-arming the grace window for as long as the arm cycle lasts.
443 if (memcmp(frame.src, this->key_extraction_ctx_.hub_node_id, NODE_ID_SIZE) != 0)
444 return;
446 return;
447 IoFrame resp;
449 ESP_LOGW(detail::TAG, "Key extraction: failed to build address response");
450 return;
451 }
452 this->send_reply_(resp);
453 this->arm_post_extraction_grace(); // Hub is still progressing — push the disarm back out.
454 ESP_LOGI(detail::TAG, "Key extraction: answered node verification request from hub %s",
455 node_id_to_string(frame.src).c_str());
456}
457
458void KeyExtractionResponder::handle_node_verify_challenge_(const IoFrame &frame) {
459 // Hub-identity guard, mirroring handle_node_verify_req_()'s: only the hub we actually exchanged keys
460 // with may drive this round.
461 if (memcmp(frame.src, this->key_extraction_ctx_.hub_node_id, NODE_ID_SIZE) != 0)
462 return;
463 // Length guard, mirroring handle_key_transfer_()'s: frame.data is passed straight into a
464 // `const uint8_t challenge[HMAC_SIZE]` parameter, so a short 0x3C would silently authenticate
465 // over stale bytes left in IoFrame::data from a previous parse.
466 if (frame.data_len < HMAC_SIZE) {
467 ESP_LOGW(detail::TAG, "Key extraction: address challenge payload too short (%u bytes)", frame.data_len);
468 return;
469 }
471 return;
472 // Rebuild the 0x37 we last sent — deterministic from ctx, nothing stored across the two calls.
473 // Only origin.cmd/origin.data/origin.data_len feed the transcript, so the dst passed here is
474 // irrelevant to the HMAC; the real builder is used anyway so the transcript cannot drift if the
475 // 0x37 payload ever changes.
476 IoFrame our_node_verify_resp;
477 if (!create_node_verify_resp_device_role(our_node_verify_resp, /*own=*/this->key_extraction_ctx_.throwaway_id,
478 /*dst=*/frame.src))
479 return;
480 IoFrame resp;
481 if (!create_challenge_resp_device_role(resp, /*dst=*/frame.src, /*src=*/this->key_extraction_ctx_.throwaway_id,
482 frame.data, our_node_verify_resp, this->key_extraction_ctx_.recovered_key)) {
483 ESP_LOGW(detail::TAG, "Key extraction: failed to build address challenge response");
484 return;
485 }
486 this->send_reply_(resp);
487 ESP_LOGI(detail::TAG, "Key extraction: answered address challenge from hub %s", node_id_to_string(frame.src).c_str());
488 // Do NOT disarm here — re-arm the grace window instead and stay in SENT_NODE_VERIFY_RESP so a
489 // retried 0x3C is answered (on_node_verify_challenge() deliberately never advances state).
491}
492
494 // Same replace-on-reschedule idiom as KEY_EXTRACTION_TIMEOUT_NAME's 10-minute timer above —
495 // every new sign of hub progress (0x36 received, 0x3D sent, and this same call at extraction
496 // time) pushes the disarm back out, so a slow multi-retry hub isn't cut off mid-round. That
497 // 10-minute timer is deliberately neither cancelled nor extended here, so it still bounds the
498 // whole arm cycle: no amount of grace-window re-arming can keep the responder listening past the
499 // 10 minutes the switch entity documents.
500 //
501 // Also pushes out key_extraction_hold_deadline_ms_ (key_extraction_responder.h) by the same
502 // window: the listen that field governs is not just for the 3 pre-extraction states
503 // KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS bounds -- EXTRACTED/SENT_NODE_VERIFY_RESP are "awaiting
504 // reply" too (a hub may still send 0x36/0x3C to verify the address it was handed), and this
505 // grace window, not the 5s mid-attempt one, is what should bound the listen during that phase.
506 // Without this, the listen would (per key_extraction_hold_deadline_ms_'s default-past-if-unset
507 // behavior) never actually engage once the responder reaches EXTRACTED, for the very phase whose
508 // whole point is catching a hub's follow-up unicast frame.
509 this->schedule_auto_off_(KEY_EXTRACTION_GRACE_TIMER_NAME, KEY_EXTRACTION_POST_EXTRACT_GRACE_MS, [this]() {
510 // Only a still-running post-extraction cycle may be disarmed from here. Checking DISARMED
511 // alone is NOT enough: a user who toggles the switch off and back on inside the grace window
512 // leaves this callback pending against a brand-new, unrelated arm cycle (set_timeout() only
513 // replaces a *pending* timer of the same name, and re-arming schedules the 10-minute auto-off
514 // timer, not this one) — and that new cycle is ARMED_IDLE, not DISARMED, so a DISARMED-only
515 // guard would let a stale callback kill it.
516 // The converse worry — that the new cycle reaches EXTRACTED (a state this guard accepts)
517 // before the stale callback fires — cannot happen: reaching EXTRACTED calls this function,
518 // whose set_timeout() replaces the pending callback under the same name. The stale timer is
519 // destroyed exactly when the state becomes acceptable to it, so the only window it can fire in
520 // is one this guard rejects.
521 const auto state = this->key_extraction_ctx_.state;
524 return;
525 ESP_LOGI(detail::TAG, "Key extraction: post-extraction grace window elapsed (stage=%s). Disarming.",
527 this->set_armed(false);
528 });
529 this->key_extraction_hold_deadline_ms_ = millis() + KEY_EXTRACTION_POST_EXTRACT_GRACE_MS;
530}
531
532// TODO(hardware-verify): an authenticated read-back to the foreign hub using the recovered key,
533// to confirm it before trusting it. recover_system_key_from_transfer()'s IV-derivation formula is
534// independently pinned against externally-captured known-answer key transfers (see the file-level
535// @warning above), but nothing here confirms this specific extraction talks to a real third-party
536// hub correctly — the single highest-risk unverified piece of this feature. That read-back subflow
537// is deliberately not implemented: doubling the protocol-speculation surface for a feature that
538// already ships marked experimental is not worth it for a second unverified vendor-hub
539// interaction. The key is still always printed (gating it on an equally-unverified secondary check
540// risks hiding a correct key), but the log below says so.
541void KeyExtractionResponder::log_result_() {
542 // Deliberate, explicit exception to redaction.h's masking — see that file and README.md's
543 // "Reporting Unsupported Devices" section, which already warns about pairing logs and the
544 // shared TRANSFER_KEY in almost identical terms. Do NOT route this through the generic
545 // frame-log helpers (log_frame()/log_component_capture()); those must keep masking 0x32.
546 //
547 // Logged line-by-line via log_multiline_result(), not as one ESP_LOGW("%s", ...) call: a single
548 // call silently truncates at ESPHome's 512-byte log buffer -- see that function's doxygen
549 // (log_helpers.h) for the root cause, and build_oneway_adoption_report()'s caller for the
550 // identical reasoning on the 1W path.
551 ESP_LOGW(detail::TAG, "========================================");
552 detail::log_multiline_result(detail::TAG, /*is_warning=*/true, /*prefix=*/"",
554 this->key_extraction_ctx_.recovered_key));
555 ESP_LOGW(detail::TAG, "========================================");
556}
557
558} // namespace home_io_control
559} // namespace esphome
Owns the per-hub device table, update callbacks, and linked-remote associations.
void generate_throwaway_id(uint8_t out[NODE_ID_SIZE])
Generate a random throwaway node ID for one key-extraction arm cycle, avoiding collisions with the br...
bool try_handle_frame(const IoFrame &frame, uint32_t rx_freq_hz=0)
Dispatch a frame to the responder if it's one of its 0x28/0x2C/0x31/0x32/0x36/0x3C frames and the res...
void set_armed(bool armed)
Arm or disarm the "Recover System Key" (key extraction) responder.
void arm_post_extraction_grace()
(Re)arm the post-extraction grace window that replaces the old immediate disarm-on-extraction: called...
pairing_responder::ResponderContext key_extraction_ctx_
State for the current "Accept Foreign Pairing" (key-extraction) arm cycle; DISARMED by default so a f...
KeyExtractionResponder(const uint8_t *node_id, RadioDriver **radio, const TuningConfig *tuning, DeviceRegistry &registry, TransmitFrameFn transmit, NamedTimeoutFn schedule_auto_off)
uint32_t key_extraction_hold_deadline_ms_
Deadline (millis()) until which loop() listens for the key-extraction responder's peer,...
Abstract radio driver for IO-Homecontrol.
Per-hub device table, update-callback fan-out, and linked-remote map.
"Recover System Key" (key extraction) — device-role responder collaborator.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
void generate_challenge(uint8_t out[HMAC_SIZE])
Generate 6 random bytes for a challenge using the ESP32 hardware RNG.
constexpr const char * TAG
Shared log tag for hub-level messages.
Definition log_helpers.h:32
void log_multiline_result(const char *tag, bool is_warning, const std::string &prefix, const std::string &message)
Log prefix followed by message, one line per log call rather than one call for the whole (possibly mu...
std::string build_key_extraction_report(const uint8_t node_id[NODE_ID_SIZE], const uint8_t key[AES_KEY_SIZE])
Build the ready-to-paste 2W system-key-extraction report: node_id:/system_key: as a home_io_control: ...
std::string format_key_hex(const uint8_t key[AES_KEY_SIZE])
Format a 16-byte key as an uppercase, unseparated hex string for display.
Definition log_helpers.h:65
bool on_key_transfer(ResponderContext &ctx, const uint8_t transfer_payload[AES_KEY_SIZE])
Decide how to react to an inbound CMD_KEY_TRANSFER (0x32) while armed.
const char * responder_stage_name(ResponderState state)
Get a short, log/telemetry-friendly name for a responder state.
bool on_discover_confirm(ResponderContext &ctx)
Decide how to react to an inbound CMD_DISCOVER_CONFIRM (0x2C) addressed to our throwaway ID.
bool on_node_verify_req(ResponderContext &ctx)
Decide how to react to an inbound CMD_NODE_VERIFY_REQ (0x36) addressed to our throwaway ID.
bool on_key_init(ResponderContext &ctx, const uint8_t challenge[HMAC_SIZE], const uint8_t hub_node_id[NODE_ID_SIZE])
Decide how to react to an inbound CMD_KEY_INIT (0x31) addressed to our throwaway ID.
bool on_node_verify_challenge(const ResponderContext &ctx)
Decide how to react to an inbound CMD_CHALLENGE_REQ (0x3C) — issued by the hub this time,...
@ ARMED_IDLE
Armed, listening for a discovery request (0x28).
@ SENT_DISCOVER_RESP
Replied to discovery (0x29); waiting for discovery-confirm (0x2C) or key-init (0x31).
@ DISARMED
Not armed; 0x28/0x2C/0x31/0x32 traffic is ignored.
@ SENT_NODE_VERIFY_RESP
Answered a hub's CMD_NODE_VERIFY_REQ (0x36) with our CMD_NODE_VERIFY_RESP (0x37); waiting for the hub...
bool on_discover_request(ResponderContext &ctx, const uint8_t hub_node_id[NODE_ID_SIZE])
Decide how to react to an inbound CMD_DISCOVER_REQ (0x28) while armed.
uint8_t discovery_slot_count(const uint8_t addr[NODE_ID_SIZE])
Number of answer slots a discovery request opens.
bool create_discover_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst, DeviceType type, uint8_t subtype, uint8_t manufacturer_id, uint8_t flags)
Build a discovery response (0x29) — device side, used only by the key-extraction responder.
bool create_node_verify_resp_device_role(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build an address response (0x37) — device side, used only by the key-extraction responder.
DeviceType
IO-Homecontrol node types.
@ UNKNOWN
Unknown/unspecified device.
bool create_key_confirm(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a key-confirm frame (0x33) — device side, used only by the key-extraction responder.
DeviceType decode_packed_device_type(uint8_t type_msb, uint8_t type_subtype)
Decode the 10-bit node type from two packed metadata bytes.
std::function< bool(const IoFrame &frame, uint32_t freq_hz, uint16_t preamble)> TransmitFrameFn
Puts a frame on air on a given channel via the hub's protected transmit_frame_().
Definition hub_hooks.h:34
bool stored_node_id_is_valid(const uint8_t id[NODE_ID_SIZE])
Check whether a node ID is usable as an address: not all-zero and not all-0xFF, the two patterns blan...
bool create_discover_confirm_ack(IoFrame &f, const uint8_t *own, const uint8_t *dst)
Build a discovery-confirm acknowledgement (0x2D) — device side, used only by the key-extraction respo...
std::string node_id_to_string(const uint8_t id[NODE_ID_SIZE])
Format a 3‑byte node ID as a 6‑character uppercase hex string.
bool create_challenge_req_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE])
Build a device-role challenge request (0x3C) — device side, used only by the key-extraction responder...
std::function< void(const char *name, uint32_t delay_ms, std::function< void()> callback)> NamedTimeoutFn
Schedules a named, replace-on-same-name timeout on the hub's ESPHome scheduler.
Definition hub_hooks.h:27
uint8_t decode_packed_device_subtype(uint8_t type_subtype)
Decode a protocol-packed device subtype from the second metadata byte.
bool create_challenge_resp_device_role(IoFrame &f, const uint8_t *dst, const uint8_t *src, const uint8_t challenge[HMAC_SIZE], const IoFrame &origin, const uint8_t *key)
Build a device-role challenge response (0x3D) — device side, used only by the key-extraction responde...
DeviceType discovery_target_type(const uint8_t addr[NODE_ID_SIZE])
Extract the device type a discovery request is restricted to.
Pure decision logic for the device-role "Accept Foreign Pairing" (system-key extraction) responder.
Command builders for the IO‑Homecontrol protocol.
Cryptographic helpers for the IO‑Homecontrol protocol.
Radio abstraction layer for IO-Homecontrol.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:93
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:96
All runtime tunable parameters for pairing and radio diagnostics.
DeviceType advertised_type
Device type advertised in our 0x29.
uint8_t throwaway_id[NODE_ID_SIZE]
Random per-arm-cycle node ID we advertise as ourselves.
uint8_t advertised_subtype
Device subtype advertised in our 0x29.
uint8_t hub_node_id[NODE_ID_SIZE]
Foreign hub's real node ID, captured from the 0x31's src.
Runtime tuning configuration for pairing and radio diagnostics.