Skip to main content

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}