Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
hub_key_extraction.cpp
Go to the documentation of this file.
1#include "hub_internal.h"
2
3#include "pairing_responder.h"
4#include "proto_commands.h"
5#include "proto_crypto.h"
6
7#include <esp_random.h>
8
9#include <cstdio>
10#include <cstring>
11
12/// @file hub_key_extraction.cpp
13/// @brief "Accept Foreign Pairing (Key Extraction)" — device-role responder hub wiring.
14/// @ingroup hioc_hub
15///
16/// Owns the impure side of the key-extraction feature: arming/disarming, throwaway node-ID
17/// generation, the 10-minute auto-off timer, transmitting device-role replies, and the
18/// security-sensitive result log block. The pure state-transition decisions live in
19/// pairing_responder.h/.cpp; the four RX branches that call into this file are in
20/// process_received_packet_() (hub_status.cpp).
21///
22/// @note Hardware-confirmed 2026-08-02: a full extraction (0x28 through 0x33) between two real
23/// boards — SX1276 running this responder, SX1262 running this project's own PairingEngine as
24/// the "hub" — recovered the hub's node_id/system_key byte-for-byte. That validates the crypto,
25/// the state machine, and the radio wiring end-to-end on real RF hardware.
26/// @warning What that test does NOT validate: compatibility with a genuine third-party hub
27/// (Somfy TaHoma/Smoove, Velux KLF200, etc.). The device-role frames built here
28/// (create_discover_resp(), create_challenge_req(), create_key_confirm()) were reverse-engineered
29/// from this project's own encoder and a small number of captures. The self-test above
30/// necessarily agrees with those conventions (it's the same codebase on both ends); a real hub's
31/// exact requirements (discovery-response field completeness, retry cadence) may still differ.
32/// recover_system_key_from_transfer()'s IV-derivation formula itself is now pinned against two
33/// (Somfy TaHoma/Smoove, Velux KLF200, etc.). That self-test necessarily agrees with this
34/// codebase's own conventions — it is the same encoder on both ends — so it is blind to two
35/// things: a device-role frame that is self-consistent but wrong on air, and a protocol step a
36/// real hub requires that this project's own controller role never sends. Both are real failure
37/// modes against real hubs; see tests/corpus/captures/issues/issue_45_*_key_extraction_stall.yaml.
38/// The device-role builders (create_discover_resp(), create_challenge_req_device_role(),
39/// create_key_confirm(), create_discover_confirm_ack()) are each pinned against a real device's
40/// captured framing by tests/corpus_device_role_builder_test.cpp — except
41/// create_discover_resp()'s flags/timestamp bytes, which remain placeholders.
42/// recover_system_key_from_transfer()'s IV-derivation formula is independently pinned against two
43/// externally-captured known-answer key transfers (ProtoCrypto.CryptKeyMatchesDocumented*Capture
44/// in proto_crypto_test.cpp), so that formula does not rest on this codebase's own conventions —
45/// though both captures are short requests and don't exercise construct_iv()'s 8-byte truncation
46/// window, so a real hub sending a longer request is an open question. Treat a recovered key as
47/// unconfirmed until it has been verified against a real hub, or by successfully controlling a
48/// device with it.
49
50namespace esphome {
51namespace home_io_control {
52
53namespace {
54
55constexpr uint32_t KEY_EXTRACTION_AUTO_OFF_MS = 10 * 60 * 1000; ///< Arm window: 10 minutes.
56// TODO(hardware-verify): confirm a real hub's pairing flow doesn't validate the advertised
57// manufacturer/type against a known-device allowlist before completing key exchange —
58// Somfy/roller-shutter is a plausible but unconfirmed default.
59constexpr uint8_t KEY_EXTRACTION_MANUFACTURER_ID = MANUFACTURER_SOMFY; ///< Plausible, widely-supported default.
60constexpr DeviceType KEY_EXTRACTION_ADVERTISED_TYPE = DeviceType::ROLLER_SHUTTER; ///< Plausible default device type.
61constexpr uint8_t KEY_EXTRACTION_ADVERTISED_SUBTYPE = 0;
62constexpr uint8_t KEY_EXTRACTION_ID_GEN_MAX_ATTEMPTS = 16; ///< Collision-retry budget for the throwaway node ID.
63constexpr const char *KEY_EXTRACTION_TIMEOUT_NAME = "key_extraction_auto_off";
64constexpr uint32_t RANDOM_LOW_BYTE_MASK = 0xFF; ///< Isolates one random byte from esp_random()'s 32-bit output.
65
66/// Format a 16-byte key as an uppercase hex string. The one deliberate place system-key bytes
67/// are formatted for display — see log_key_extraction_result_() and redaction.h.
68std::string format_key_hex(const uint8_t key[AES_KEY_SIZE]) {
69 std::string out;
70 out.reserve(AES_KEY_SIZE * 2);
71 char byte_buf[3];
72 for (uint8_t i = 0; i < AES_KEY_SIZE; i++) {
73 snprintf(byte_buf, sizeof(byte_buf), "%02X", key[i]);
74 out += byte_buf;
75 }
76 return out;
77}
78
79} // namespace
80
82 for (uint8_t attempt = 0; attempt < KEY_EXTRACTION_ID_GEN_MAX_ATTEMPTS; attempt++) {
83 for (uint8_t i = 0; i < NODE_ID_SIZE; i++)
84 out[i] = static_cast<uint8_t>(esp_random() & RANDOM_LOW_BYTE_MASK);
86 continue;
87 if (memcmp(out, this->node_id_, NODE_ID_SIZE) == 0)
88 continue;
89 if (memcmp(out, BROADCAST_DISCOVER, NODE_ID_SIZE) == 0 || memcmp(out, BROADCAST_DISCOVER_ALT, NODE_ID_SIZE) == 0)
90 continue;
91 if (this->registry_.get(node_id_to_string(out)) != nullptr)
92 continue;
93 return;
94 }
95 // Every attempt collided (astronomically unlikely for a 3-byte space against a handful of
96 // reserved/registered IDs) — fall through and use the last-generated candidate rather than
97 // leaving the buffer stale; a false collision here only degrades to "discovery/key-init from
98 // the colliding real device also gets intercepted," not a crash or security issue.
99}
100
102 if (!armed) {
104 return;
106 ESP_LOGI(detail::TAG, "Key extraction: disarmed");
109 return;
110 }
111
114 this->key_extraction_ctx_.advertised_type = KEY_EXTRACTION_ADVERTISED_TYPE;
115 this->key_extraction_ctx_.advertised_subtype = KEY_EXTRACTION_ADVERTISED_SUBTYPE;
117
118 ESP_LOGW(detail::TAG,
119 "Key extraction: ARMED for 10 minutes, throwaway ID %s. Put your existing hub into pairing/add-device "
120 "mode now.",
121 node_id_to_string(this->key_extraction_ctx_.throwaway_id).c_str());
122
123 this->set_timeout(KEY_EXTRACTION_TIMEOUT_NAME, KEY_EXTRACTION_AUTO_OFF_MS, [this]() {
124 // Guards against a stale timeout firing after a manual disarm/re-arm already ran; this hub's
125 // set_timeout() replaces any pending callback with the same name, but the check is cheap
126 // insurance and documents the intent either way.
128 return;
130 ESP_LOGW(detail::TAG, "Key extraction: window expired, no pairing attempt seen. Disarming.");
131 } else {
132 ESP_LOGW(detail::TAG, "Key extraction: window expired while in progress (reached stage=%s). Disarming.",
134 }
135 this->set_key_extraction_armed(false);
136 });
137
140}
141
144 return false;
145
146 if (frame.cmd == CMD_DISCOVER_REQ) {
148 return true;
149 }
150 if (memcmp(frame.dst, this->key_extraction_ctx_.throwaway_id, NODE_ID_SIZE) != 0)
151 return false;
152 if (frame.cmd == CMD_DISCOVER_CONFIRM) {
154 return true;
155 }
156 if (frame.cmd == CMD_KEY_INIT) {
158 return true;
159 }
160 if (frame.cmd == CMD_KEY_TRANSFER) {
162 return true;
163 }
164 return false;
165}
166
168 // Broadcast on all 3 channels like the CMD_STATUS_UPDATE_RESP ack in hub_status.cpp: we don't
169 // know which channel the foreign hub is listening on after transmitting its own frame. Use the
170 // driver's own response_preamble() (12 bytes for SX1276, 8 for SX1262) rather than a flat
171 // SHORT_PREAMBLE(8) or LONG_PREAMBLE(1024) constant — this is the same chip-tuned "reply, not a
172 // cold start" preamble ExchangeEngine/PairingEngine already use for every other reply in this
173 // codebase (see exchange_engine.cpp, pairing_engine.cpp), and it exists for exactly this
174 // problem: long enough that a channel-hopping receiver reliably lands on it, short enough that
175 // 3 sequential channel transmissions don't block the main loop for the better part of a second
176 // (hardware-confirmed 2026-08-02: LONG_PREAMBLE on all 3 channels blocked long enough to blow
177 // through the hub's tight per-try wait windows and broke both directions).
178 const uint16_t preamble = this->radio_->response_preamble();
179 this->transmit_frame_(frame, FREQ_CH1, preamble);
180 this->transmit_frame_(frame, FREQ_CH2, preamble);
181 this->transmit_frame_(frame, FREQ_CH3, preamble);
182}
183
186 return;
187
188 IoFrame resp;
189 if (!create_discover_resp(resp, this->key_extraction_ctx_.throwaway_id, frame.src,
190 this->key_extraction_ctx_.advertised_type, this->key_extraction_ctx_.advertised_subtype,
191 KEY_EXTRACTION_MANUFACTURER_ID)) {
192 ESP_LOGW(detail::TAG, "Key extraction: failed to build discovery response");
193 return;
194 }
196 ESP_LOGI(detail::TAG, "Key extraction: replied to discovery from hub %s with throwaway ID %s",
197 node_id_to_string(frame.src).c_str(), node_id_to_string(this->key_extraction_ctx_.throwaway_id).c_str());
198}
199
202 return;
203
204 IoFrame resp;
205 if (!create_discover_confirm_ack(resp, this->key_extraction_ctx_.throwaway_id, frame.src)) {
206 ESP_LOGW(detail::TAG, "Key extraction: failed to build discovery-confirm ack");
207 return;
208 }
210 ESP_LOGI(detail::TAG, "Key extraction: acknowledged discovery confirm from hub %s",
211 node_id_to_string(frame.src).c_str());
212}
213
215 uint8_t candidate_challenge[HMAC_SIZE];
216 crypto::generate_challenge(candidate_challenge);
217 if (!pairing_responder::on_key_init(this->key_extraction_ctx_, candidate_challenge, frame.src))
218 return;
219
220 IoFrame resp;
221 if (!create_challenge_req_device_role(resp, frame.src, this->key_extraction_ctx_.throwaway_id,
222 this->key_extraction_ctx_.challenge)) {
223 ESP_LOGW(detail::TAG, "Key extraction: failed to build challenge request");
224 return;
225 }
227 ESP_LOGI(detail::TAG, "Key extraction: sent challenge to hub %s", node_id_to_string(frame.src).c_str());
228}
229
231 if (frame.data_len < AES_KEY_SIZE) {
232 ESP_LOGW(detail::TAG, "Key extraction: key-transfer payload too short (%u bytes)", frame.data_len);
233 return;
234 }
236 return;
237
238 IoFrame resp;
239 if (create_key_confirm(resp, this->key_extraction_ctx_.throwaway_id, frame.src)) {
241 } else {
242 ESP_LOGW(detail::TAG, "Key extraction: failed to build key confirm");
243 }
244
245 // Log before disarming: disarm resets key_extraction_ctx_, which is where the recovered key
246 // and the hub's real node ID live.
248 // Immediately disarm: a second hub attempting to pair mid-window must not also succeed and
249 // produce a second, confusing log block (see the feature plan's rollout notes).
250 this->set_key_extraction_armed(false);
251}
252
253// TODO(hardware-verify): an authenticated read-back to the foreign hub using the recovered key,
254// to confirm it before trusting it. recover_system_key_from_transfer()'s IV-derivation formula is
255// independently pinned against externally-captured known-answer key transfers (see the file-level
256// @warning above), but nothing here confirms this specific extraction talks to a real third-party
257// hub correctly — the single highest-risk unverified piece of this feature. That read-back subflow
258// is deliberately not implemented: it would require carving a narrow exception into
259// is_exchange_internal_command()'s 0x3C/0x3D early-drop (hub_status.cpp) for a second unverified
260// vendor-hub interaction, doubling the protocol-speculation surface for a feature that already
261// ships marked experimental. The key is still always printed (gating it on an equally-unverified
262// secondary check risks hiding a correct key), but the log below says so.
264 const std::string node_id_str = node_id_to_string(this->key_extraction_ctx_.hub_node_id);
265 const std::string key_str = format_key_hex(this->key_extraction_ctx_.recovered_key);
266 // Deliberate, explicit exception to redaction.h's masking — see that file and README.md's
267 // "Reporting Unsupported Devices" section, which already warns about pairing logs and the
268 // shared TRANSFER_KEY in almost identical terms. Do NOT route this through the generic
269 // frame-log helpers (log_frame()/log_component_capture()); those must keep masking 0x32.
270 ESP_LOGW(detail::TAG, "========================================");
271 ESP_LOGW(detail::TAG, "SYSTEM KEY EXTRACTED -- DO NOT SHARE YOUR SYSTEM KEY");
272 ESP_LOGW(detail::TAG, "Anyone with this key and node_id can control every device on this installation.");
273 ESP_LOGW(detail::TAG, "This exchange has not been independently confirmed against your specific hub -- test");
274 ESP_LOGW(detail::TAG, "this key (e.g. by controlling a device with it) before relying on it.");
275 ESP_LOGW(detail::TAG, "Copy the block below into a new hub's YAML.");
276 ESP_LOGW(detail::TAG, "========================================");
277 ESP_LOGW(detail::TAG, "home_io_control:");
278 ESP_LOGW(detail::TAG, " node_id: \"%s\"", node_id_str.c_str());
279 ESP_LOGW(detail::TAG, " system_key: \"%s\"", key_str.c_str());
280 ESP_LOGW(detail::TAG, "========================================");
281}
282
283} // namespace home_io_control
284} // namespace esphome
void handle_key_extraction_discover_confirm_(const IoFrame &frame)
Handle an inbound CMD_DISCOVER_CONFIRM (0x2C) addressed to our throwaway node ID while armed.
void log_key_extraction_result_()
Emit the security-sensitive "system key extracted" log block (see redaction.h — this is the one delib...
virtual void set_key_extraction_armed(bool armed)
Arm or disarm the "Accept Foreign Pairing (Key Extraction)" responder.
void handle_key_extraction_key_init_(const IoFrame &frame)
Handle an inbound CMD_KEY_INIT (0x31) addressed to our throwaway node ID while armed.
std::function< void(bool)> key_extraction_armed_callback_
Invoked whenever the key-extraction armed state changes; see set_key_extraction_armed_callback().
Definition hub_core.h:759
void generate_key_extraction_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 handle_key_extraction_key_transfer_(const IoFrame &frame)
Handle an inbound CMD_KEY_TRANSFER (0x32) addressed to our throwaway node ID while armed.
bool transmit_frame_(const IoFrame &frame, uint32_t freq, uint16_t preamble)
Transmit a raw IoFrame on the current frequency with given preamble length.
Definition hub_core.cpp:259
void handle_key_extraction_discover_(const IoFrame &frame)
Handle an inbound CMD_DISCOVER_REQ (0x28) while the key-extraction responder is armed.
pairing_responder::ResponderContext key_extraction_ctx_
State for the current "Accept Foreign Pairing" (key-extraction) arm cycle; DISARMED by default so a f...
Definition hub_core.h:757
bool try_handle_key_extraction_frame_(const IoFrame &frame)
Dispatch a frame to the key-extraction responder if it's one of its 0x28/0x2C/0x31/0x32 frames and th...
void broadcast_key_extraction_reply_(const IoFrame &frame)
Transmit a key-extraction reply frame on all 3 IO-homecontrol channels, using the radio driver's resp...
Internal helpers shared by the hub implementation .cpp files.
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.
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_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.
@ ARMED_IDLE
Armed, listening for a discovery request (0x28).
@ DISARMED
Not armed; 0x28/0x2C/0x31/0x32 traffic is ignored.
bool on_discover_request(ResponderContext &ctx)
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.
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_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.
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:814
static constexpr uint32_t FREQ_CH2
Channel 2: 868.95 MHz (1W and 2W, TX channel).
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.
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
static constexpr uint8_t MANUFACTURER_SOMFY
Somfy (shutters, awnings, blinds).
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.
Parsed IO‑Homecontrol frame (CTRL0/1 + addresses + command + data).
Definition proto_frame.h:71
uint8_t data[FRAME_MAX_DATA_SIZE]
Command parameters (0–23 bytes).
Definition proto_frame.h:77
uint8_t src[NODE_ID_SIZE]
Source node ID (3 bytes).
Definition proto_frame.h:75
uint8_t dst[NODE_ID_SIZE]
Destination node ID (3 bytes).
Definition proto_frame.h:74
uint8_t data_len
Actual length of data.
Definition proto_frame.h:78