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 "hub_internal.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
18/// @file key_extraction_responder.cpp
19/// @brief "Recover System Key" (key extraction) — device-role responder collaborator.
20/// @ingroup hioc_hub
21///
22/// Owns the impure side of the key-extraction feature: arming/disarming, throwaway node-ID
23/// generation, the 10-minute auto-off timer, the post-extraction grace window, transmitting
24/// device-role replies, and the security-sensitive result log block. The pure state-transition
25/// decisions live in pairing_responder.h/.cpp; the six RX branches
26/// (0x28/0x2C/0x31/0x32/0x36/0x3C) dispatch through try_handle_frame(), called from
27/// process_received_packet_() (hub_status.cpp).
28///
29/// @note Hardware-confirmed 2026-08-02: a full extraction (0x28 through 0x33) between two real
30/// boards — SX1276 running this responder, SX1262 running this project's own PairingEngine as
31/// the "hub" — recovered the hub's node_id/system_key byte-for-byte. That validates the crypto,
32/// the state machine, and the radio wiring end-to-end on real RF hardware.
33/// @warning What that test does NOT validate: compatibility with a genuine third-party hub
34/// (Somfy TaHoma/Smoove, Velux KLF200, etc.). The device-role frames built here
35/// (create_discover_resp(), create_challenge_req(), create_key_confirm()) were reverse-engineered
36/// from this project's own encoder and a small number of captures. The self-test above
37/// necessarily agrees with those conventions (it's the same codebase on both ends); a real hub's
38/// exact requirements (discovery-response field completeness, retry cadence) may still differ. It
39/// is blind to two things in particular: a device-role frame that is self-consistent but wrong on
40/// air, and a protocol step a real hub requires that this project's own controller role never
41/// sends. Both are real failure modes against real hubs; see
42/// tests/corpus/captures/pairing/velux_kig300_pairing_key_extraction_stall.yaml and
43/// tests/corpus/captures/pairing/somfy_connectivity_kit_pairing_key_extraction_stall.yaml.
44///
45/// The device-role builders (create_discover_resp(), create_challenge_req_device_role(),
46/// create_key_confirm(), create_discover_confirm_ack()) are each pinned against a real device's
47/// captured framing by tests/corpus_device_role_builder_test.cpp, including
48/// create_discover_resp()'s flags/timestamp bytes, which mirror a real Somfy Izymo dimmer's
49/// captured values (see KEY_EXTRACTION_DISCOVER_RESP_FLAGS/_TIMESTAMP in proto_commands.cpp for
50/// the derivation) but remain unconfirmed against a real hub like every device-role field here.
51///
52/// recover_system_key_from_transfer()'s IV-derivation formula itself is independently pinned
53/// against two externally-captured known-answer key transfers
54/// (ProtoCrypto.CryptKeyMatchesDocumented*Capture in proto_crypto_test.cpp), so that formula does
55/// not rest on this codebase's own conventions — though both captures are short requests and don't
56/// exercise construct_iv()'s 8-byte truncation window, so a real hub sending a longer request is an
57/// open question. Treat a recovered key as unconfirmed until it has been verified against a real
58/// hub, or by successfully controlling a device with it.
59
60namespace esphome {
61namespace home_io_control {
62
63namespace {
64
65constexpr uint32_t KEY_EXTRACTION_AUTO_OFF_MS = 10 * 60 * 1000; ///< Arm window: 10 minutes.
66// TODO(hardware-verify): confirm a real hub's pairing flow doesn't validate the advertised
67// manufacturer/type against a known-device allowlist before completing key exchange —
68// Somfy/roller-shutter is a plausible but unconfirmed default.
69constexpr uint8_t KEY_EXTRACTION_MANUFACTURER_ID = MANUFACTURER_SOMFY; ///< Plausible, widely-supported default.
70constexpr DeviceType KEY_EXTRACTION_ADVERTISED_TYPE = DeviceType::ROLLER_SHUTTER; ///< Plausible default device type.
71constexpr uint8_t KEY_EXTRACTION_ADVERTISED_SUBTYPE = 0;
72constexpr uint8_t KEY_EXTRACTION_ID_GEN_MAX_ATTEMPTS = 16; ///< Collision-retry budget for the throwaway node ID.
73constexpr const char *KEY_EXTRACTION_TIMEOUT_NAME = "key_extraction_auto_off";
74constexpr uint32_t RANDOM_LOW_BYTE_MASK = 0xFF; ///< Isolates one random byte from esp_random()'s 32-bit output.
75
76constexpr uint32_t KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS = 5000; ///< 5 seconds.
77// Bounds the CH2 hold (key_extraction_responder.h's key_extraction_hold_deadline_ms_ /
78// awaiting_reply()) for the 3 pre-extraction states (SENT_DISCOVER_RESP, SENT_CONFIRM_ACK,
79// SENT_CHALLENGE), none of which has a hold bound of its own otherwise -- CMD_DISCOVER_REQ is
80// handled before the throwaway-ID dst filter (try_handle_frame()), so *any* 0x28 from any hub in
81// range that then goes silent would otherwise pin CH2 for the rest of the arm window.
82//
83// This is purely a radio-scheduling optimization, not a protocol-recovery deadline: expiry only
84// stops loop() from holding CH2 for this responder (see key_extraction_hold_deadline_ms_'s doc
85// comment in key_extraction_responder.h) -- it does NOT touch key_extraction_ctx_.state, so a real
86// hub's frame arriving even slightly late is still accepted by the pure guards in
87// pairing_responder.cpp exactly as if the hold were still active, just without the CH2-parking
88// benefit for that one frame. That makes the cost of sizing this too small merely "occasionally
89// idle-hops away from CH2 a little early," not "silently drops a live attempt" -- so 5s is sized
90// generously rather than tightly: a real hub that's still trying should have its very next frame
91// land well inside this window -- comfortably above a single retry gap (EXCHANGE_RETRY_DELAY_MS=250ms)
92// plus the request/response windows either side of it
93// (PAIRING_KEY_CHALLENGE_TIMEOUT_MS/PAIRING_KEY_CONFIRM_TIMEOUT_MS=500ms each, pairing_engine.h) --
94// while staying "a few seconds", not minutes, sized against third-party hub timing (the whole
95// reason this feature exists), not this project's own controller role. Not hardware-measured;
96// deliberately generous rather than tight, matching KEY_EXTRACTION_POST_EXTRACT_GRACE_MS's own
97// reasoning below.
98
99constexpr uint32_t KEY_EXTRACTION_POST_EXTRACT_GRACE_MS = 60000; ///< One minute.
100// TODO(hardware-verify): no timing data exists for the 0x33->0x36 gap on real hardware (the only
101// capture of that gap is an untimed SPI trace). One minute is deliberately generous rather than
102// tight: every pre-EXTRACTED guard in pairing_responder.cpp still rejects EXTRACTED/SENT_ADDRESS_
103// RESP for 0x2C/0x31/0x32, so a *different* hub cannot interfere with a live round inside this
104// window. on_discover_request() is the one exception, deliberately: it accepts a fresh 0x28 from
105// the *same* hub as ctx.hub_node_id (see that function's doxygen for why), so the window is inert
106// to every hub except the one it's actually running a round with. It is still hard-capped by the
107// 10-minute auto-off timer above, and the only cost of overshooting is that the HA switch reports
108// "still listening" for longer. Undershooting, by contrast, silently drops a slow hub's
109// address-verification round — the exact failure this feature exists to fix. Not measured.
110constexpr const char *KEY_EXTRACTION_GRACE_TIMER_NAME = "key_extraction_post_extract_grace";
111
112} // namespace
113
114KeyExtractionResponder::KeyExtractionResponder(const uint8_t *node_id, RadioDriver **radio, const TuningConfig *tuning,
115 DeviceRegistry &registry, TransmitFrameFn transmit,
116 NamedTimeoutFn schedule_auto_off)
117 : node_id_(node_id),
118 radio_(radio),
119 tuning_(tuning),
120 registry_(registry),
121 transmit_(std::move(transmit)),
122 schedule_auto_off_(std::move(schedule_auto_off)) {}
123
125 for (uint8_t attempt = 0; attempt < KEY_EXTRACTION_ID_GEN_MAX_ATTEMPTS; attempt++) {
126 for (uint8_t i = 0; i < NODE_ID_SIZE; i++)
127 out[i] = static_cast<uint8_t>(esp_random() & RANDOM_LOW_BYTE_MASK);
128 if (!stored_node_id_is_valid(out))
129 continue;
130 if (memcmp(out, this->node_id_, NODE_ID_SIZE) == 0)
131 continue;
132 if (memcmp(out, BROADCAST_DISCOVER, NODE_ID_SIZE) == 0 || memcmp(out, BROADCAST_DISCOVER_ALT, NODE_ID_SIZE) == 0)
133 continue;
134 if (this->registry_.get(node_id_to_string(out)) != nullptr)
135 continue;
136 return;
137 }
138 // Every attempt collided (astronomically unlikely for a 3-byte space against a handful of
139 // reserved/registered IDs) — fall through and use the last-generated candidate rather than
140 // leaving the buffer stale; a false collision here only degrades to "discovery/key-init from
141 // the colliding real device also gets intercepted," not a crash or security issue.
142}
143
145 if (!armed) {
147 return;
149 ESP_LOGI(detail::TAG, "Key extraction: disarmed");
150 if (this->armed_callback_)
151 this->armed_callback_(false);
152 return;
153 }
154
156 this->generate_throwaway_id(this->key_extraction_ctx_.throwaway_id);
157 this->key_extraction_ctx_.advertised_type = KEY_EXTRACTION_ADVERTISED_TYPE;
158 this->key_extraction_ctx_.advertised_subtype = KEY_EXTRACTION_ADVERTISED_SUBTYPE;
160
161 ESP_LOGW(detail::TAG,
162 "Key extraction: ARMED for 10 minutes, throwaway ID %s. Put your existing hub into pairing/add-device "
163 "mode now.",
164 node_id_to_string(this->key_extraction_ctx_.throwaway_id).c_str());
165
166 // This timer and arm_post_extraction_grace()'s below share one idiom (named set_timeout(),
167 // guarded by a state check so a stale callback from a disarm-and-rearm inside the window can't
168 // act on the wrong cycle, logging, then disarming) — two call sites, not enough to be worth
169 // extracting into a shared helper at the cost of an extra layer of indirection between the
170 // guard condition and what it's guarding.
171 this->schedule_auto_off_(KEY_EXTRACTION_TIMEOUT_NAME, KEY_EXTRACTION_AUTO_OFF_MS, [this]() {
172 // Guards against a stale timeout firing after a manual disarm/re-arm already ran; this hub's
173 // set_timeout() replaces any pending callback with the same name, but the check is cheap
174 // insurance and documents the intent either way.
176 return;
178 ESP_LOGW(detail::TAG, "Key extraction: window expired, no pairing attempt seen. Disarming.");
179 } else {
180 ESP_LOGW(detail::TAG, "Key extraction: window expired while in progress (reached stage=%s). Disarming.",
182 }
183 this->set_armed(false);
184 });
185
186 if (this->armed_callback_)
187 this->armed_callback_(true);
188}
189
192 return false;
193
194 if (frame.cmd == CMD_DISCOVER_REQ) {
195 this->handle_discover_(frame);
196 return true;
197 }
198 if (memcmp(frame.dst, this->key_extraction_ctx_.throwaway_id, NODE_ID_SIZE) != 0)
199 return false;
200 if (frame.cmd == CMD_DISCOVER_CONFIRM) {
201 this->handle_discover_confirm_(frame);
202 return true;
203 }
204 if (frame.cmd == CMD_KEY_INIT) {
205 this->handle_key_init_(frame);
206 return true;
207 }
208 if (frame.cmd == CMD_KEY_TRANSFER) {
209 this->handle_key_transfer_(frame);
210 return true;
211 }
212 if (frame.cmd == CMD_ADDRESS_REQ) {
213 this->handle_address_req_(frame);
214 return true;
215 }
216 if (frame.cmd == CMD_CHALLENGE_REQ) {
217 this->handle_address_challenge_(frame);
218 return true;
219 }
220 return false;
221}
222
223void KeyExtractionResponder::broadcast_reply_(const IoFrame &frame) {
224 // Broadcast on all 3 channels like the CMD_STATUS_UPDATE_RESP ack in hub_status.cpp: we don't
225 // know which channel the foreign hub is listening on after transmitting its own frame.
226 //
227 // The preamble choice mirrors ExchangeEngine::send_and_receive() (exchange_engine.cpp), which
228 // already picks between a long and a short preamble via is_start() for the controller-role
229 // outbound path: a start-flagged frame (currently only 0x29, this responder's discovery reply)
230 // is the one reply a hopping/scanning peer has to catch cold, so it gets
231 // `cold_broadcast_reply_preamble` — long enough for that, short enough that broadcasting it on
232 // 3 channels doesn't meaningfully block the loop (~12x cheaper per leg than LONG_PREAMBLE by
233 // default). Every other device-role reply (0x2D, 0x3C, 0x33, 0x37, 0x3D) is `start=false` — it lands
234 // on a channel the peer already holds, so it keeps the driver's chip-tuned response_preamble()
235 // (12 bytes for SX1276, 8 for SX1262/LR1121), same as every other in-exchange reply in this
236 // codebase.
237 //
238 // Do NOT widen this to a flat LONG_PREAMBLE(1024) for every reply: hardware-confirmed
239 // 2026-08-02, that blocked the main loop long enough to blow through the hub's tight per-try
240 // wait windows and broke both directions. Scoping the long preamble to only the start-flagged
241 // reply is what keeps that regression from recurring while still fixing the hopping-catch case.
242 //
243 // CH2 goes last, deliberately: CH2 is the channel every non-discovery peer listen holds still
244 // on (unicast requests always go out on CH2 — see wait_for_key_challenge_()'s and
245 // wait_for_key_confirm_()'s own doc comments), so it's the one leg whose *completion* the peer
246 // is waiting to react to. Transmitting it mid-sequence would let a peer that hears it and
247 // replies immediately land its next frame while this responder is still transmitting a later
248 // leg — a structural TX-deafness miss, regardless of hop timing. Firing it last avoids that: by
249 // the time the peer reacts to hearing CH2, this responder has already finished transmitting and
250 // re-armed RX. This doesn't cost the one reply that behaves differently (0x29 discovery, whose
251 // peer listen explicitly *skips* CH2 — ROTATE_SKIPPING_REQUEST) anything either: CH1/CH3 (the
252 // channels that listen actually scans) both go out before CH2 instead of straddling it, so
253 // discovery reaches its useful channels sooner.
254 const uint16_t preamble =
255 is_start(frame) ? this->tuning_->cold_broadcast_reply_preamble : (*this->radio_)->response_preamble();
256 this->transmit_(frame, FREQ_CH1, preamble);
257 this->transmit_(frame, FREQ_CH3, preamble);
258 this->transmit_(frame, FREQ_CH2, preamble);
259}
260
261void KeyExtractionResponder::handle_discover_(const IoFrame &frame) {
263 return;
264
265 IoFrame resp;
266 if (!create_discover_resp(resp, this->key_extraction_ctx_.throwaway_id, frame.src,
267 this->key_extraction_ctx_.advertised_type, this->key_extraction_ctx_.advertised_subtype,
268 KEY_EXTRACTION_MANUFACTURER_ID)) {
269 ESP_LOGW(detail::TAG, "Key extraction: failed to build discovery response");
270 // Deliberately does NOT touch key_extraction_hold_deadline_ms_: on_discover_request() already
271 // advanced ctx.state above, but a failed builder means no reply went out, so there is nothing
272 // for the hub to be replying to yet. Leaving the deadline exactly as it was (0 on a fresh arm,
273 // safely "already expired" per key_extraction_hold_deadline_ms_'s doc comment; or whatever an
274 // earlier successful reply set it to, still correctly bounded) is what keeps a builder failure
275 // from either holding CH2 unboundedly or clobbering a still-valid earlier deadline.
276 return;
277 }
278 this->broadcast_reply_(resp);
279 this->key_extraction_hold_deadline_ms_ = millis() + KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS;
280 ESP_LOGI(detail::TAG, "Key extraction: replied to discovery from hub %s with throwaway ID %s",
281 node_id_to_string(frame.src).c_str(), node_id_to_string(this->key_extraction_ctx_.throwaway_id).c_str());
282}
283
284void KeyExtractionResponder::handle_discover_confirm_(const IoFrame &frame) {
286 return;
287
288 IoFrame resp;
289 if (!create_discover_confirm_ack(resp, this->key_extraction_ctx_.throwaway_id, frame.src)) {
290 ESP_LOGW(detail::TAG, "Key extraction: failed to build discovery-confirm ack");
291 // See handle_discover_()'s matching comment: leaving the hold deadline untouched on a builder
292 // failure is deliberate, not an oversight.
293 return;
294 }
295 this->broadcast_reply_(resp);
296 this->key_extraction_hold_deadline_ms_ = millis() + KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS;
297 ESP_LOGI(detail::TAG, "Key extraction: acknowledged discovery confirm from hub %s",
298 node_id_to_string(frame.src).c_str());
299}
300
301void KeyExtractionResponder::handle_key_init_(const IoFrame &frame) {
302 uint8_t candidate_challenge[HMAC_SIZE];
303 crypto::generate_challenge(candidate_challenge);
304 if (!pairing_responder::on_key_init(this->key_extraction_ctx_, candidate_challenge, frame.src))
305 return;
306
307 IoFrame resp;
308 if (!create_challenge_req_device_role(resp, frame.src, this->key_extraction_ctx_.throwaway_id,
309 this->key_extraction_ctx_.challenge)) {
310 ESP_LOGW(detail::TAG, "Key extraction: failed to build challenge request");
311 // See handle_discover_()'s matching comment: leaving the hold deadline untouched on a builder
312 // failure is deliberate, not an oversight.
313 return;
314 }
315 this->broadcast_reply_(resp);
316 this->key_extraction_hold_deadline_ms_ = millis() + KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS;
317 ESP_LOGI(detail::TAG, "Key extraction: sent challenge to hub %s", node_id_to_string(frame.src).c_str());
318}
319
320void KeyExtractionResponder::handle_key_transfer_(const IoFrame &frame) {
321 if (frame.data_len < AES_KEY_SIZE) {
322 ESP_LOGW(detail::TAG, "Key extraction: key-transfer payload too short (%u bytes)", frame.data_len);
323 return;
324 }
326 return;
327
328 IoFrame resp;
329 if (create_key_confirm(resp, this->key_extraction_ctx_.throwaway_id, frame.src)) {
330 this->broadcast_reply_(resp);
331 } else {
332 ESP_LOGW(detail::TAG, "Key extraction: failed to build key confirm");
333 }
334
335 // Log before the grace window can disarm us: disarm resets key_extraction_ctx_, which is where
336 // the recovered key and the hub's real node ID live.
337 this->log_result_();
338 ESP_LOGI(detail::TAG,
339 "Key extraction: still listening for up to %" PRIu32
340 " more seconds in case the hub verifies this device's address (CMD_ADDRESS_REQ/0x36) — leave the "
341 "switch on until it turns off on its own.",
342 KEY_EXTRACTION_POST_EXTRACT_GRACE_MS / 1000);
343 // Don't disarm immediately: some hubs (Velux KLR200) follow the key exchange with an address
344 // request (0x36) and a challenge (0x3C) verifying it, and disarming here would make the
345 // responder deaf to that round before it can happen. A *different* hub attempting to pair
346 // mid-window still cannot succeed and produce a second, confusing log block — every pure guard in
347 // pairing_responder.cpp except on_discover_request() unconditionally rejects EXTRACTED/
348 // SENT_ADDRESS_RESP, and on_discover_request() itself only accepts a fresh 0x28 from that same
349 // hub_node_id, so a different hub's traffic still cannot advance the state machine backwards. The
350 // grace timer below disarms once no further progress is seen from the real hub, instead of doing
351 // it at once.
353}
354
355void KeyExtractionResponder::handle_address_req_(const IoFrame &frame) {
356 // Our throwaway ID is not a secret -- it went out in clear in our own 0x29/0x37 -- so the dst
357 // check in try_handle_frame() alone doesn't establish this frame actually came from the hub we
358 // exchanged keys with. hub_node_id was captured from the 0x31 that started this attempt
359 // (pairing_responder::on_key_init()); anything else claiming our throwaway ID as dst is not that
360 // hub and gets no reply, closing an otherwise-unbounded loop an onlooker could drive to keep
361 // re-arming the grace window for as long as the arm cycle lasts.
362 if (memcmp(frame.src, this->key_extraction_ctx_.hub_node_id, NODE_ID_SIZE) != 0)
363 return;
365 return;
366 IoFrame resp;
367 if (!create_address_resp_device_role(resp, this->key_extraction_ctx_.throwaway_id, frame.src)) {
368 ESP_LOGW(detail::TAG, "Key extraction: failed to build address response");
369 return;
370 }
371 this->broadcast_reply_(resp);
372 this->arm_post_extraction_grace(); // Hub is still progressing — push the disarm back out.
373 ESP_LOGI(detail::TAG, "Key extraction: answered address request from hub %s", node_id_to_string(frame.src).c_str());
374}
375
376void KeyExtractionResponder::handle_address_challenge_(const IoFrame &frame) {
377 // Hub-identity guard, mirroring handle_address_req_()'s: only the hub we actually exchanged keys
378 // with may drive this round.
379 if (memcmp(frame.src, this->key_extraction_ctx_.hub_node_id, NODE_ID_SIZE) != 0)
380 return;
381 // Length guard, mirroring handle_key_transfer_()'s: frame.data is passed straight into a
382 // `const uint8_t challenge[HMAC_SIZE]` parameter, so a short 0x3C would silently authenticate
383 // over stale bytes left in IoFrame::data from a previous parse.
384 if (frame.data_len < HMAC_SIZE) {
385 ESP_LOGW(detail::TAG, "Key extraction: address challenge payload too short (%u bytes)", frame.data_len);
386 return;
387 }
389 return;
390 // Rebuild the 0x37 we last sent — deterministic from ctx, nothing stored across the two calls.
391 // Only origin.cmd/origin.data/origin.data_len feed the transcript, so the dst passed here is
392 // irrelevant to the HMAC; the real builder is used anyway so the transcript cannot drift if the
393 // 0x37 payload ever changes.
394 IoFrame our_address_resp;
395 if (!create_address_resp_device_role(our_address_resp, /*own=*/this->key_extraction_ctx_.throwaway_id,
396 /*dst=*/frame.src))
397 return;
398 IoFrame resp;
399 if (!create_challenge_resp_device_role(resp, /*dst=*/frame.src, /*src=*/this->key_extraction_ctx_.throwaway_id,
400 frame.data, our_address_resp, this->key_extraction_ctx_.recovered_key)) {
401 ESP_LOGW(detail::TAG, "Key extraction: failed to build address challenge response");
402 return;
403 }
404 this->broadcast_reply_(resp);
405 ESP_LOGI(detail::TAG, "Key extraction: answered address challenge from hub %s", node_id_to_string(frame.src).c_str());
406 // Do NOT disarm here — re-arm the grace window instead and stay in SENT_ADDRESS_RESP so a
407 // retried 0x3C is answered (on_address_challenge() deliberately never advances state).
409}
410
412 // Same replace-on-reschedule idiom as KEY_EXTRACTION_TIMEOUT_NAME's 10-minute timer above —
413 // every new sign of hub progress (0x36 received, 0x3D sent, and this same call at extraction
414 // time) pushes the disarm back out, so a slow multi-retry hub isn't cut off mid-round. That
415 // 10-minute timer is deliberately neither cancelled nor extended here, so it still bounds the
416 // whole arm cycle: no amount of grace-window re-arming can keep the responder listening past the
417 // 10 minutes the switch entity documents.
418 //
419 // Also pushes out key_extraction_hold_deadline_ms_ (key_extraction_responder.h) by the same
420 // window: the CH2 hold that field governs is not just for the 3 pre-extraction states
421 // KEY_EXTRACTION_MID_ATTEMPT_TIMEOUT_MS bounds -- EXTRACTED/SENT_ADDRESS_RESP are "awaiting
422 // reply" too (a hub may still send 0x36/0x3C to verify the address it was handed), and this
423 // grace window, not the 5s mid-attempt one, is what should bound the hold during that phase.
424 // Without this, the hold would (per key_extraction_hold_deadline_ms_'s default-past-if-unset
425 // behavior) never actually engage once the responder reaches EXTRACTED, silently losing the
426 // CH2-hold benefit for the very phase whose whole point is catching a hub's follow-up unicast
427 // frame.
428 this->schedule_auto_off_(KEY_EXTRACTION_GRACE_TIMER_NAME, KEY_EXTRACTION_POST_EXTRACT_GRACE_MS, [this]() {
429 // Only a still-running post-extraction cycle may be disarmed from here. Checking DISARMED
430 // alone is NOT enough: a user who toggles the switch off and back on inside the grace window
431 // leaves this callback pending against a brand-new, unrelated arm cycle (set_timeout() only
432 // replaces a *pending* timer of the same name, and re-arming schedules the 10-minute auto-off
433 // timer, not this one) — and that new cycle is ARMED_IDLE, not DISARMED, so a DISARMED-only
434 // guard would let a stale callback kill it.
435 // The converse worry — that the new cycle reaches EXTRACTED (a state this guard accepts)
436 // before the stale callback fires — cannot happen: reaching EXTRACTED calls this function,
437 // whose set_timeout() replaces the pending callback under the same name. The stale timer is
438 // destroyed exactly when the state becomes acceptable to it, so the only window it can fire in
439 // is one this guard rejects.
440 const auto state = this->key_extraction_ctx_.state;
443 return;
444 ESP_LOGI(detail::TAG, "Key extraction: post-extraction grace window elapsed (stage=%s). Disarming.",
446 this->set_armed(false);
447 });
448 this->key_extraction_hold_deadline_ms_ = millis() + KEY_EXTRACTION_POST_EXTRACT_GRACE_MS;
449}
450
451// TODO(hardware-verify): an authenticated read-back to the foreign hub using the recovered key,
452// to confirm it before trusting it. recover_system_key_from_transfer()'s IV-derivation formula is
453// independently pinned against externally-captured known-answer key transfers (see the file-level
454// @warning above), but nothing here confirms this specific extraction talks to a real third-party
455// hub correctly — the single highest-risk unverified piece of this feature. That read-back subflow
456// is deliberately not implemented: doubling the protocol-speculation surface for a feature that
457// already ships marked experimental is not worth it for a second unverified vendor-hub
458// interaction. The key is still always printed (gating it on an equally-unverified secondary check
459// risks hiding a correct key), but the log below says so.
460void KeyExtractionResponder::log_result_() {
461 // Deliberate, explicit exception to redaction.h's masking — see that file and README.md's
462 // "Reporting Unsupported Devices" section, which already warns about pairing logs and the
463 // shared TRANSFER_KEY in almost identical terms. Do NOT route this through the generic
464 // frame-log helpers (log_frame()/log_component_capture()); those must keep masking 0x32.
465 //
466 // Logged line-by-line via log_multiline_result(), not as one ESP_LOGW("%s", ...) call: a single
467 // call silently truncates at ESPHome's 512-byte log buffer -- see that function's doxygen
468 // (hub_internal.h) for the root cause, and build_oneway_adoption_report()'s caller for the
469 // identical reasoning on the 1W path.
470 ESP_LOGW(detail::TAG, "========================================");
471 detail::log_multiline_result(detail::TAG, /*is_warning=*/true, /*prefix=*/"",
473 this->key_extraction_ctx_.recovered_key));
474 ESP_LOGW(detail::TAG, "========================================");
475}
476
477} // namespace home_io_control
478} // 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...
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...
bool try_handle_frame(const IoFrame &frame)
Dispatch a frame to the responder if it's one of its 0x28/0x2C/0x31/0x32/0x36/0x3C frames and the res...
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() should hold CH2 for the key-extraction responder,...
Abstract radio driver for IO-Homecontrol.
Per-hub device table, update-callback fan-out, and linked-remote map.
Internal helpers shared by the hub implementation .cpp files.
"Recover System Key" (key extraction) — device-role responder collaborator.
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.
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: ...
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_address_challenge(const ResponderContext &ctx)
Decide how to react to an inbound CMD_CHALLENGE_REQ (0x3C) — issued by the hub this time,...
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_address_req(ResponderContext &ctx)
Decide how to react to an inbound CMD_ADDRESS_REQ (0x36) addressed to our throwaway ID.
@ ARMED_IDLE
Armed, listening for a discovery request (0x28).
@ DISARMED
Not armed; 0x28/0x2C/0x31/0x32 traffic is ignored.
@ SENT_ADDRESS_RESP
Answered a hub's CMD_ADDRESS_REQ (0x36) with our CMD_ADDRESS_RESP (0x37); waiting for the hub's own C...
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.
static constexpr uint8_t CMD_DISCOVER_REQ
Broadcast discovery request.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
static constexpr uint8_t CMD_KEY_TRANSFER
Send encrypted system key to device.
DeviceType
Device type identifiers reported by IO‑Homecontrol products.
bool create_discover_resp(IoFrame &f, const uint8_t *own, const uint8_t *dst, DeviceType type, uint8_t subtype, uint8_t manufacturer_id)
Build a discovery response (0x29) — device side, used only by the key-extraction responder.
bool is_start(const IoFrame &f)
Check START flag.
static constexpr uint32_t FREQ_CH1
The protocol uses 3 frequency channels in the 868 MHz ISM band.
static constexpr uint32_t FREQ_CH3
Channel 3: 869.85 MHz (2W only).
static constexpr uint8_t HMAC_SIZE
Authentication HMAC is 6 bytes (truncated AES output).
Definition proto_sizes.h:22
bool create_address_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.
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.
static constexpr uint8_t CMD_KEY_INIT
Initiate key transfer to device.
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 if a stored node ID is valid (not all-zero, not all-0xFF).
Definition hub_core.h:1144
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
static constexpr uint8_t CMD_CHALLENGE_REQ
6-byte random challenge.
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...
static constexpr uint8_t CMD_DISCOVER_CONFIRM
Confirm discovery to device.
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
static constexpr uint8_t BROADCAST_DISCOVER[NODE_ID_SIZE]
Broadcast address for device discovery (0x00003B).
static constexpr uint8_t BROADCAST_DISCOVER_ALT[NODE_ID_SIZE]
Alternate discovery / 1W broadcast address (0x00003F).
static constexpr uint8_t AES_KEY_SIZE
AES-128 key size.
Definition proto_sizes.h:23
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...
static constexpr uint8_t MANUFACTURER_SOMFY
Somfy (shutters, awnings, blinds).
static constexpr uint8_t CMD_ADDRESS_REQ
"Report your address" request.
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:88
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:91
All runtime tunable parameters for pairing and radio diagnostics.
uint16_t cold_broadcast_reply_preamble
Preamble for a start-flagged key-extraction broadcast reply (0x29).
uint8_t throwaway_id[NODE_ID_SIZE]
Random per-arm-cycle node ID we advertise as ourselves.
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.