Skip to main content

hsm_firmware/
state.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//! State machine task.
17//!
18//! Owns the single instance of [`TokenState`] and applies transitions in
19//! response to events posted on [`crate::channels::EVENT_CHANNEL`]. After
20//! each transition it republishes the new state on
21//! [`crate::channels::TOKEN_STATE`] so the animation task can update the
22//! LEDs, and pulses [`crate::channels::TOUCH_CONFIRMED`] when it enters
23//! the [`TokenState::Signing`] state so the dispatch loop can resume.
24//!
25//! # Internal timers
26//!
27//! The state task races [`EVENT_CHANNEL`] against two optional deadlines:
28//!
29//! - **Session timeout**: armed when entering [`TokenState::Authenticated`].
30//!   Re-armed on every event that proves the user is still active (a
31//!   successful `SignComplete`, or a fresh `PinVerified`). If the deadline
32//!   fires first, the task posts [`Event::SessionEnded`] internally, which
33//!   drops the SM back to [`TokenState::Idle`].
34//! - **Error display window**: armed when entering [`TokenState::Error`].
35//!   When the deadline elapses, posts [`Event::ErrorDisplayElapsed`] which
36//!   returns the SM to [`TokenState::Idle`].
37//!
38//! Both deadlines share the same race future via `select3`; whichever
39//! fires first wins. The non-armed case uses a far-future deadline so it
40//! cannot interrupt the channel receive.
41
42use defmt::info;
43use embassy_futures::select::{select, Either};
44use embassy_time::{Duration, Instant, Timer};
45
46use hsm_crypto_service::SESSION_TIMEOUT_MS;
47use hsm_firmware_logic::{Event, TokenState, ERROR_DISPLAY_MS};
48
49use crate::channels::{EVENT_CHANNEL, TOKEN_STATE, TOUCH_CONFIRMED};
50
51/// Drive the token state machine. Spawn once at boot.
52#[embassy_executor::task]
53pub(crate) async fn state_task() -> !
54{
55    let mut state = TokenState::initial();
56    // Publish initial state immediately so the animation task does not
57    // have to wait for the first event to know what to display.
58    TOKEN_STATE.signal(state);
59
60    // Deadlines for the two timers. `None` means the timer is disarmed.
61    let mut session_deadline: Option<Instant> = None;
62    let mut error_deadline:   Option<Instant> = None;
63
64    loop
65    {
66        // Compute the earliest pending deadline. If both timers are
67        // disarmed, pick a deadline far in the future so the select
68        // effectively waits on the channel only.
69        let deadline = earliest(session_deadline, error_deadline);
70        let now = Instant::now();
71        let wait = if deadline > now
72        {
73            deadline - now
74        }
75        else
76        {
77            // Deadline already in the past: fire the timer arm
78            // immediately by waiting zero. This handles the case where
79            // the channel was busy when the deadline passed.
80            Duration::from_ticks(0)
81        };
82
83        let next_event = match select(EVENT_CHANNEL.receive(), Timer::after(wait)).await
84        {
85            Either::First(event) => event,
86            Either::Second(()) =>
87            {
88                // A timer expired. Figure out which one and translate it
89                // to the appropriate internal event.
90                derive_timer_event(&mut session_deadline, &mut error_deadline)
91            }
92        };
93
94        let next = state.on_event(next_event);
95
96        if next != state
97        {
98            info!("state {:?} -> {:?} on event {:?}", state, next, next_event);
99            state = next;
100            TOKEN_STATE.signal(state);
101
102            if state == TokenState::Signing
103            {
104                // Wake the dispatch loop so it can perform the actual
105                // signing operation now that the user has touched.
106                TOUCH_CONFIRMED.signal(());
107            }
108        }
109
110        // Arm or refresh the relevant timer based on the new (or unchanged)
111        // state and the event we just consumed.
112        update_timers(state, next_event, &mut session_deadline, &mut error_deadline);
113    }
114}
115
116/// Return the earlier of two optional deadlines, or a far-future deadline
117/// if neither is armed.
118fn earliest(a: Option<Instant>, b: Option<Instant>) -> Instant
119{
120    match (a, b)
121    {
122        (Some(x), Some(y)) => if x < y { x } else { y },
123        (Some(x), None)    => x,
124        (None,    Some(y)) => y,
125        // 1 hour from now: any future tick is fine, the timer will be
126        // re-armed before then by the normal event flow.
127        (None,    None)    => Instant::now() + Duration::from_secs(3600),
128    }
129}
130
131/// Translate a timer expiration into the corresponding internal event.
132///
133/// Inspects which deadline is in the past (relative to now) and clears
134/// it, returning the matching `Event`. If both are simultaneously in the
135/// past, the session timeout wins (it is the more semantically meaningful
136/// of the two).
137fn derive_timer_event(
138    session_deadline: &mut Option<Instant>,
139    error_deadline:   &mut Option<Instant>,
140) -> Event
141{
142    let now = Instant::now();
143    if let Some(d) = *session_deadline
144        && d <= now
145        {
146            *session_deadline = None;
147            return Event::SessionEnded;
148        }
149    if let Some(d) = *error_deadline
150        && d <= now
151        {
152            *error_deadline = None;
153            return Event::ErrorDisplayElapsed;
154        }
155    // Should not happen: the select's timer branch fired but no deadline
156    // matched. Most likely a logic bug. Fall back to a no-op event.
157    defmt::warn!("timer fired but no deadline matched");
158    Event::SessionEnded
159}
160
161/// Re-arm or clear the timers based on the SM state and the latest event.
162///
163/// - Entering [`TokenState::Authenticated`] arms the session timer.
164/// - Each `PinVerified` event refreshes the session timer (the user is
165///   active).
166/// - A successful `SignComplete` also refreshes the session timer.
167/// - Leaving `Authenticated` (or its children: `WaitingForTouch`, `Signing`)
168///   for `Idle` or `Error` clears the session timer.
169/// - Entering `Error` arms the error display timer.
170/// - Leaving `Error` clears the error display timer.
171fn update_timers(
172    state:            TokenState,
173    last_event:       Event,
174    session_deadline: &mut Option<Instant>,
175    error_deadline:   &mut Option<Instant>,
176)
177{
178    // Session timer: any of the three "user is active" states.
179    let user_active_state = matches!
180    (
181        state,
182        TokenState::Authenticated | TokenState::WaitingForTouch | TokenState::Signing
183    );
184
185    if user_active_state
186    {
187        // Refresh on activity events.
188        let refresh = matches!
189        (
190            last_event,
191            Event::PinVerified | Event::SignComplete | Event::SignRequested | Event::TouchPressed
192        );
193        if refresh || session_deadline.is_none()
194        {
195            *session_deadline =
196                Some(Instant::now() + Duration::from_millis(SESSION_TIMEOUT_MS));
197        }
198    }
199    else
200    {
201        *session_deadline = None;
202    }
203
204    // Error display timer.
205    if state == TokenState::Error
206    {
207        if error_deadline.is_none()
208        {
209            *error_deadline =
210                Some(Instant::now() + Duration::from_millis(ERROR_DISPLAY_MS));
211        }
212    }
213    else
214    {
215        *error_deadline = None;
216    }
217}