hsm_firmware_logic/state_machine.rs
1// Copyright (c) 2026 Tuloup Simon
2//
3// This program is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// any later version.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Token operating state machine.
17//!
18//! The token transitions between a small set of named states based on
19//! external events: USB boot complete, PIN verified, sign request received,
20//! touch detected, timer elapsed, error reported. Each state has an
21//! associated LED pattern that drives the visual indicator at the right
22//! cadence.
23//!
24//! The state machine is pure logic. It consumes events and returns the
25//! new state plus the new LED pattern. It does not perform I/O. The
26//! firmware's main task drives the I/O around it (sampling the button,
27//! toggling the LEDs, waking on timeouts). This separation makes the
28//! machine fully testable in a host context.
29
30/// All the operating states the token can be in.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[cfg_attr(feature = "defmt", derive(defmt::Format))]
33pub enum TokenState
34{
35 /// Just powered on, peripherals coming up. Yellow LED blinks.
36 Booting,
37 /// Idle, waiting for a host command. Green solid.
38 Idle,
39 /// PIN session is active and the user can request a signature. Green
40 /// slow pulse.
41 Authenticated,
42 /// A Sign request is pending and the user must press the touch button
43 /// within [`TOUCH_TIMEOUT_MS`]. Yellow solid.
44 WaitingForTouch,
45 /// Touch confirmed, signing in progress. Both LEDs alternate briefly.
46 Signing,
47 /// An error occurred. Green LED fast-blinks for [`ERROR_DISPLAY_MS`]
48 /// then returns to [`TokenState::Idle`].
49 Error,
50}
51
52/// How long the token displays the error pattern before returning to idle.
53pub const ERROR_DISPLAY_MS: u64 = 5_000;
54
55/// How long the user has to touch the button before the signing request
56/// is cancelled.
57pub const TOUCH_TIMEOUT_MS: u64 = 30_000;
58
59// Compile-time invariant: the error display window must be shorter than
60// the touch window, otherwise an error raised during signing could hide
61// the pending signature for longer than the touch deadline. Catching
62// this at compile time means a misedit of the constants above is
63// rejected by `cargo check`, before any test ever runs.
64const _: () = assert!(ERROR_DISPLAY_MS < TOUCH_TIMEOUT_MS);
65
66/// Events that drive transitions.
67///
68/// Events come from three sources: the dispatch loop (PIN verified, sign
69/// requested), the touch sampler (touch pressed), and the timer task
70/// (timeout elapsed). Each variant maps onto a single transition in
71/// [`TokenState::on_event`].
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73#[cfg_attr(feature = "defmt", derive(defmt::Format))]
74pub enum Event
75{
76 /// Peripherals are ready, USB has enumerated.
77 BootComplete,
78 /// A PIN session has just been opened.
79 PinVerified,
80 /// The PIN session timed out or was closed.
81 SessionEnded,
82 /// A `Sign` request just landed; user must touch.
83 SignRequested,
84 /// The touch button was pressed (debounced).
85 TouchPressed,
86 /// The touch-waiting period ran out without a press.
87 TouchTimeout,
88 /// The signing operation finished (signature returned to host).
89 SignComplete,
90 /// An error happened. Display the error pattern then return to idle.
91 ErrorRaised,
92 /// The error display window ran out.
93 ErrorDisplayElapsed,
94}
95
96/// Visual LED pattern associated with each state.
97///
98/// The firmware's animation task reads this from the current state and
99/// toggles the LEDs accordingly. Patterns are expressed in human terms
100/// (solid, pulse, alternate) rather than as raw on/off booleans so the
101/// animation task can vary its cadence without the state machine knowing.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103#[cfg_attr(feature = "defmt", derive(defmt::Format))]
104pub enum LedPattern
105{
106 /// Both LEDs off.
107 AllOff,
108 /// Green LED solid on.
109 GreenSolid,
110 /// Green LED pulsing slowly (about 1 Hz, smooth).
111 GreenSlowPulse,
112 /// Green LED fast-blinking (about 5 Hz).
113 GreenFastBlink,
114 /// Yellow LED solid on.
115 YellowSolid,
116 /// Yellow LED blinking (about 2 Hz).
117 YellowBlink,
118 /// Green and yellow alternate (about 5 Hz).
119 AlternateBoth,
120}
121
122impl TokenState
123{
124 /// Initial state at power-on.
125 #[must_use]
126 pub const fn initial() -> Self
127 {
128 TokenState::Booting
129 }
130
131 /// LED pattern associated with this state.
132 #[must_use]
133 pub const fn led_pattern(self) -> LedPattern
134 {
135 match self
136 {
137 TokenState::Booting => LedPattern::YellowBlink,
138 TokenState::Idle => LedPattern::GreenSolid,
139 TokenState::Authenticated => LedPattern::GreenSlowPulse,
140 TokenState::WaitingForTouch => LedPattern::YellowSolid,
141 TokenState::Signing => LedPattern::AlternateBoth,
142 TokenState::Error => LedPattern::GreenFastBlink,
143 }
144 }
145
146 /// Apply an event and return the next state.
147 ///
148 /// Events that do not match any transition for the current state are
149 /// ignored. The function returns the state unchanged. This is the
150 /// conservative behaviour, equivalent to a `default: do nothing` arm.
151 /// The dispatch loop is free to fire `SessionEnded` repeatedly without
152 /// risking pathological transitions for example.
153 #[must_use]
154 pub const fn on_event(self, event: Event) -> Self
155 {
156 use Event as E;
157 use TokenState as S;
158 match (self, event)
159 {
160 // Transitions to Idle: boot completion, session timeout (from either
161 // authenticated or waiting-for-touch), and the end of the error
162 // display window.
163 (S::Booting, E::BootComplete)
164 | (S::Authenticated | S::WaitingForTouch, E::SessionEnded)
165 | (S::Error, E::ErrorDisplayElapsed) => S::Idle,
166
167 // Transitions to Authenticated: PIN verify (Idle or refresh existing),
168 // touch timeout cancelling a pending sign without dropping session,
169 // and a completed signing.
170 (S::Idle | S::Authenticated, E::PinVerified)
171 | (S::WaitingForTouch, E::TouchTimeout)
172 | (S::Signing, E::SignComplete) => S::Authenticated,
173
174 // Sign requires authentication.
175 (S::Authenticated, E::SignRequested) => S::WaitingForTouch,
176
177 // Touch confirms the pending signature.
178 (S::WaitingForTouch, E::TouchPressed) => S::Signing,
179
180 // Errors override any state.
181 (_, E::ErrorRaised) => S::Error,
182
183 // Anything else is a no-op.
184 _ => self,
185 }
186 }
187}
188
189#[cfg(test)]
190mod tests
191{
192 use super::*;
193
194 #[test]
195 fn led_pattern_per_state()
196 {
197 assert_eq!(TokenState::Booting.led_pattern(), LedPattern::YellowBlink);
198 assert_eq!(TokenState::Idle.led_pattern(), LedPattern::GreenSolid);
199 assert_eq!(TokenState::Authenticated.led_pattern(), LedPattern::GreenSlowPulse);
200 assert_eq!(TokenState::WaitingForTouch.led_pattern(), LedPattern::YellowSolid);
201 assert_eq!(TokenState::Signing.led_pattern(), LedPattern::AlternateBoth);
202 assert_eq!(TokenState::Error.led_pattern(), LedPattern::GreenFastBlink);
203 }
204
205 #[test]
206 fn happy_path_boot_to_signed()
207 {
208 let s = TokenState::initial();
209 assert_eq!(s, TokenState::Booting);
210
211 let s = s.on_event(Event::BootComplete);
212 assert_eq!(s, TokenState::Idle);
213
214 let s = s.on_event(Event::PinVerified);
215 assert_eq!(s, TokenState::Authenticated);
216
217 let s = s.on_event(Event::SignRequested);
218 assert_eq!(s, TokenState::WaitingForTouch);
219
220 let s = s.on_event(Event::TouchPressed);
221 assert_eq!(s, TokenState::Signing);
222
223 let s = s.on_event(Event::SignComplete);
224 assert_eq!(s, TokenState::Authenticated);
225 }
226
227 #[test]
228 fn touch_timeout_returns_to_authenticated()
229 {
230 let s = TokenState::Authenticated
231 .on_event(Event::SignRequested)
232 .on_event(Event::TouchTimeout);
233 assert_eq!(s, TokenState::Authenticated);
234 }
235
236 #[test]
237 fn session_expiry_returns_to_idle_from_authenticated()
238 {
239 let s = TokenState::Authenticated.on_event(Event::SessionEnded);
240 assert_eq!(s, TokenState::Idle);
241 }
242
243 #[test]
244 fn session_expiry_returns_to_idle_from_waiting_for_touch()
245 {
246 let s = TokenState::WaitingForTouch.on_event(Event::SessionEnded);
247 assert_eq!(s, TokenState::Idle);
248 }
249
250 #[test]
251 fn sign_request_ignored_outside_authenticated()
252 {
253 for s in [
254 TokenState::Booting,
255 TokenState::Idle,
256 TokenState::WaitingForTouch,
257 TokenState::Signing,
258 TokenState::Error,
259 ]
260 {
261 assert_eq!(s.on_event(Event::SignRequested), s,
262 "sign request from {s:?} should be a no-op");
263 }
264 }
265
266 #[test]
267 fn touch_press_ignored_outside_waiting()
268 {
269 for s in [
270 TokenState::Booting,
271 TokenState::Idle,
272 TokenState::Authenticated,
273 TokenState::Signing,
274 TokenState::Error,
275 ]
276 {
277 assert_eq!(s.on_event(Event::TouchPressed), s,
278 "touch press from {s:?} should be a no-op");
279 }
280 }
281
282 #[test]
283 fn error_event_overrides_any_state()
284 {
285 for s in [
286 TokenState::Booting,
287 TokenState::Idle,
288 TokenState::Authenticated,
289 TokenState::WaitingForTouch,
290 TokenState::Signing,
291 TokenState::Error,
292 ]
293 {
294 assert_eq!(s.on_event(Event::ErrorRaised), TokenState::Error,
295 "{s:?} should transition to Error on ErrorRaised");
296 }
297 }
298
299 #[test]
300 fn error_display_elapsed_returns_to_idle()
301 {
302 let s = TokenState::Error.on_event(Event::ErrorDisplayElapsed);
303 assert_eq!(s, TokenState::Idle);
304 }
305
306 #[test]
307 fn error_display_elapsed_outside_error_is_noop()
308 {
309 for s in [
310 TokenState::Booting,
311 TokenState::Idle,
312 TokenState::Authenticated,
313 TokenState::WaitingForTouch,
314 TokenState::Signing,
315 ]
316 {
317 assert_eq!(s.on_event(Event::ErrorDisplayElapsed), s,
318 "ErrorDisplayElapsed from {s:?} should be a no-op");
319 }
320 }
321
322 #[test]
323 fn repeated_pin_verified_stays_authenticated()
324 {
325 let s = TokenState::Idle
326 .on_event(Event::PinVerified)
327 .on_event(Event::PinVerified)
328 .on_event(Event::PinVerified);
329 assert_eq!(s, TokenState::Authenticated);
330 }
331
332 #[test]
333 fn repeated_session_ended_stays_idle()
334 {
335 let s = TokenState::Idle
336 .on_event(Event::SessionEnded)
337 .on_event(Event::SessionEnded);
338 assert_eq!(s, TokenState::Idle);
339 }
340
341 #[test]
342 fn timeout_constants_are_reasonable()
343 {
344 // Touch window matches the user-facing spec.
345 assert_eq!(TOUCH_TIMEOUT_MS, 30_000);
346 }
347}