Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
oneway_sequence_store.h
Go to the documentation of this file.
1#pragma once
2
3/// @file oneway_sequence_store.h
4/// @brief Persistent rolling-sequence counters for one-way (1W) transmit.
5/// @ingroup hioc_hub
6///
7/// 1W has no reply, no challenge and no acknowledgement. Its entire replay defence is a rolling
8/// 16-bit sequence carried in every frame and mixed into the authenticator's IV: a device
9/// remembers the highest sequence it has accepted from each transmitter and rejects anything at
10/// or below it. That makes this counter the one piece of state the hub cannot afford to lose,
11/// and the reason it is the only thing the component writes to persistent storage — see ADR 0025,
12/// which records that exception to ADR 0018 and draws its boundary.
13///
14/// The failure is asymmetric, and every rule below follows from it:
15///
16/// - **Skipping sequences is safe.** A device accepts a forward jump within its acceptance
17/// window, so a counter that runs ahead costs nothing.
18/// - **Reusing one is not.** The device rejects it as a replay.
19/// - **Falling behind is unrecoverable without intervention.** Every later command is rejected
20/// too, permanently, and *silently* — 1W emits no error, so a hub with a stale counter keeps
21/// transmitting well-formed, correctly-signed frames that nothing acts on.
22///
23/// This class is the only place in the component that increments or persists a sequence.
24
25#include "proto_sizes.h"
26
27#include "esphome/core/preferences.h"
28
29#include <cstdint>
30#include <vector>
31
32namespace esphome {
33namespace home_io_control {
34
35// === Sequence-safety bounds ===
36
37/// How far ahead of its stored high-water mark a device will still accept a jump. 1000 is the
38/// window the iohomecontrol reference receiver applies, and it is the ceiling every
39/// forward skip in this file has to stay under: a jump past it fails exactly like a stale
40/// counter, and just as silently.
41static constexpr uint16_t ONEWAY_SEQUENCE_ACCEPTANCE_WINDOW = 1000;
42
43/// How many sequences one flash write reserves.
44///
45/// Persisting on every command would write flash on every button press. Reserving a block
46/// instead trades that for a bounded forward skip: an unclean reboot forfeits the block's unused
47/// remainder, which is the safe direction. Small on purpose — see the static_assert below for
48/// the bound, and raise it only if that bound still holds.
49static constexpr uint16_t ONEWAY_SEQUENCE_STRIDE = 8;
50
51/// How many consecutive unclean reboots the stride must survive while staying inside a device's
52/// acceptance window. Eight is far past plausible; the point is headroom, not precision.
53static constexpr uint16_t ONEWAY_SEQUENCE_REBOOT_HEADROOM = 8;
54
56 "the stride must let many unclean reboots in a row still land inside a device's "
57 "acceptance window — a larger stride buys fewer flash writes and pays in silent desync");
58
59// === The store ===
60
61/// @brief Per-controller-identity rolling sequence counters, persisted across reboots.
62/// @ingroup hioc_hub
63///
64/// Keyed on the transmitting node address, not on the command: real capture logs show one remote
65/// running a single counter across command types (0x01 → 0x00 → 0x20 at 2416 → 2417 → 2418), so
66/// a per-command counter would not match what devices track.
67///
68/// One logical command consumes exactly one sequence. next() is called once, by whatever builds
69/// the command — never inside a repeat loop. A 4-frame burst carrying four different sequences
70/// is not one command to a device, and burns counter space four times as fast.
72 public:
73 /// @brief Register a controller identity and load its persisted counter.
74 ///
75 /// Call once per identity at setup. With nothing persisted the counter starts at
76 /// `initial_sequence`; otherwise it resumes from whichever of the two is *higher*. That keeps
77 /// `initial_sequence` usable as the day-one remedy for a desynced device — raise it, reflash,
78 /// and the counter jumps ahead — while making it structurally unable to drag a live counter
79 /// backwards into replay territory. Use seed() when moving backwards is what you actually mean.
80 /// @param node_id 3-byte source address this identity transmits as.
81 /// @param initial_sequence Value to start from when nothing is persisted, or to jump forward to.
82 void add_identity(const uint8_t node_id[NODE_ID_SIZE], uint16_t initial_sequence);
83
84 /// @brief Reserve and return the next sequence for an identity.
85 ///
86 /// Durably reserves before returning: when the in-RAM block is exhausted this persists the end
87 /// of the next block *and syncs it to flash* before handing anything back, so a caller can never
88 /// transmit a value the hub has not already committed to never reusing. A crash between the
89 /// write and the transmit costs one skipped sequence; the reverse ordering would risk a reuse.
90 /// @param node_id Identity's 3-byte source address.
91 /// @param out Output: the sequence to transmit.
92 /// @return false if the address is not a registered identity, or if the reservation could not
93 /// be persisted — in both cases nothing is handed out and nothing may be transmitted.
94 bool next(const uint8_t node_id[NODE_ID_SIZE], uint16_t &out);
95
96 /// @brief Force an identity's counter to a specific value and persist it immediately.
97 ///
98 /// Unlike add_identity(), this *may* move the counter backwards, which is the whole point —
99 /// re-seed from a sequence observed on air, or from a user's estimate, when a counter has
100 /// desynced from the device. Today's documented remedy for a desynced counter is
101 /// `initial_sequence:` (docs/home_io_control.md troubleshooting), which goes through
102 /// add_identity() at boot instead.
103 /// @param node_id Identity's 3-byte source address.
104 /// @param value Next sequence to hand out.
105 /// @return false if the address is not registered or the write could not be persisted.
106 bool seed(const uint8_t node_id[NODE_ID_SIZE], uint16_t value);
107
108 /// @brief The next sequence this identity would hand out, without reserving it.
109 ///
110 /// Unlike next(), this does not commit to transmitting the value it returns — it does not
111 /// reserve, persist, or advance the counter.
112 /// @param node_id Identity's 3-byte source address.
113 /// @param out Output: the sequence next() would return.
114 /// @return false if the address is not a registered identity.
115 bool peek(const uint8_t node_id[NODE_ID_SIZE], uint16_t &out) const;
116
117 private:
118 /// One identity's counter: the value to hand out next, the end of the block already reserved
119 /// in flash, and the preference backing it.
120 struct Counter {
121 uint8_t node_id[NODE_ID_SIZE]{};
122 uint16_t next{0}; ///< Next value to hand out.
123 uint16_t reserved{0}; ///< First value NOT covered by the persisted reservation.
124 ESPPreferenceObject pref;
125 };
126
127 Counter *find_(const uint8_t node_id[NODE_ID_SIZE]);
128 const Counter *find_(const uint8_t node_id[NODE_ID_SIZE]) const;
129
130 std::vector<Counter> counters_;
131};
132
133} // namespace home_io_control
134} // namespace esphome
Per-controller-identity rolling sequence counters, persisted across reboots.
bool seed(const uint8_t node_id[NODE_ID_SIZE], uint16_t value)
Force an identity's counter to a specific value and persist it immediately.
void add_identity(const uint8_t node_id[NODE_ID_SIZE], uint16_t initial_sequence)
Register a controller identity and load its persisted counter.
bool next(const uint8_t node_id[NODE_ID_SIZE], uint16_t &out)
Reserve and return the next sequence for an identity.
bool peek(const uint8_t node_id[NODE_ID_SIZE], uint16_t &out) const
The next sequence this identity would hand out, without reserving it.
static constexpr uint8_t NODE_ID_SIZE
Device/node addresses are 3 bytes (e.g., "123ABC").
Definition proto_sizes.h:20
static constexpr uint16_t ONEWAY_SEQUENCE_REBOOT_HEADROOM
How many consecutive unclean reboots the stride must survive while staying inside a device's acceptan...
static constexpr uint16_t ONEWAY_SEQUENCE_ACCEPTANCE_WINDOW
How far ahead of its stored high-water mark a device will still accept a jump.
static constexpr uint16_t ONEWAY_SEQUENCE_STRIDE
How many sequences one flash write reserves.
Fundamental IO-Homecontrol frame and crypto size constants.