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}