1use std::collections::{HashMap, HashSet};
9use std::sync::atomic::Ordering;
10
11use super::{App, PendingRetry, Screen};
12use crate::security_key::{self as sk, PinFlow, RefusalReason, SecurityKeyPin, SecurityKeyRun};
13
14#[derive(Clone, PartialEq, Eq)]
16pub struct PinTrial {
17 pub alias: String,
18 pub key_path: String,
19 pub pin: String,
20 pub flow: PinFlow,
21}
22
23impl std::fmt::Debug for PinTrial {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 f.debug_struct("PinTrial")
27 .field("alias", &self.alias)
28 .field("key_path", &self.key_path)
29 .field("flow", &self.flow)
30 .finish_non_exhaustive()
31 }
32}
33
34#[derive(Default)]
36pub struct SecurityKeyPins {
37 confirmed: HashMap<String, String>,
38 trial: Option<PinTrial>,
39}
40
41impl std::fmt::Debug for SecurityKeyPins {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 let mut keys: Vec<&String> = self.confirmed.keys().collect();
45 keys.sort();
46 f.debug_struct("SecurityKeyPins")
47 .field("confirmed", &keys)
48 .field("trial", &self.trial)
49 .finish()
50 }
51}
52
53impl SecurityKeyPins {
54 pub fn start_trial(&mut self, trial: PinTrial) {
56 self.trial = Some(trial);
57 }
58
59 pub fn trial(&self) -> Option<&PinTrial> {
60 self.trial.as_ref()
61 }
62
63 fn take_trial_for(&mut self, alias: &str, flow: PinFlow) -> Option<PinTrial> {
64 if self
65 .trial
66 .as_ref()
67 .is_some_and(|t| t.alias == alias && t.flow == flow)
68 {
69 self.trial.take()
70 } else {
71 None
72 }
73 }
74
75 pub fn confirmed_pin(&self, key_path: &str) -> Option<&str> {
77 self.confirmed.get(key_path).map(String::as_str)
78 }
79
80 fn pins_for(
83 &self,
84 paths: Option<&crate::runtime::env::Paths>,
85 trial_for: Option<(&str, PinFlow)>,
86 ) -> Vec<SecurityKeyPin> {
87 let mut pins: Vec<SecurityKeyPin> = self
88 .confirmed
89 .iter()
90 .filter(|(key, _)| sk::refusal_for(paths, key).is_none())
91 .map(|(key, pin)| SecurityKeyPin {
92 key_path: key.clone(),
93 pin: pin.clone(),
94 })
95 .collect();
96 if let (Some(trial), Some((alias, flow))) = (&self.trial, trial_for)
97 && trial.alias == alias
98 && trial.flow == flow
99 {
100 pins.retain(|p| p.key_path != trial.key_path);
101 pins.push(SecurityKeyPin {
102 key_path: trial.key_path.clone(),
103 pin: trial.pin.clone(),
104 });
105 }
106 pins.sort_by(|a, b| a.key_path.cmp(&b.key_path));
107 pins
108 }
109
110 pub(crate) fn run_for(
112 &self,
113 paths: Option<&crate::runtime::env::Paths>,
114 alias: &str,
115 flow: PinFlow,
116 ) -> SecurityKeyRun {
117 SecurityKeyRun {
118 pins: self.pins_for(paths, Some((alias, flow))),
119 flow: Some(flow),
120 }
121 }
122
123 pub fn drop_trial(&mut self, flow: PinFlow, alias: Option<&str>) -> bool {
126 if self
127 .trial
128 .as_ref()
129 .is_some_and(|t| t.flow == flow && alias.is_none_or(|a| t.alias == a))
130 {
131 self.trial = None;
132 return true;
133 }
134 false
135 }
136
137 pub fn prune_aliases(&mut self, valid: &HashSet<&str>) -> bool {
139 if self
140 .trial
141 .as_ref()
142 .is_some_and(|t| !valid.contains(t.alias.as_str()))
143 {
144 self.trial = None;
145 return true;
146 }
147 false
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum RunEnd {
154 Connected,
156 AuthRefused,
158 OtherFailure,
160}
161
162impl RunEnd {
163 pub fn from_run(success: bool, stderr: &str) -> Self {
165 if success {
166 RunEnd::Connected
167 } else if crate::connection::auth_refused(stderr) {
168 RunEnd::AuthRefused
169 } else {
170 RunEnd::OtherFailure
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum PinOutcome {
178 NotInvolved,
179 Needed {
181 key_path: String,
182 },
183 Rejected {
186 key_path: String,
187 reason: RefusalReason,
188 connected: bool,
189 },
190}
191
192impl App {
193 pub(crate) fn security_key_pins_allowed(&self) -> bool {
196 *self.ssh_allows_security_key_pins.get_or_init(|| {
197 let version = crate::logging::query_ssh_version().unwrap_or_default();
198 let allowed = sk::ssh_version_allows_pins(&version);
199 log::debug!("[purple] security key: PIN hand-out allowed={allowed} ssh={version}");
200 allowed
201 })
202 }
203
204 pub(crate) fn seed_ssh_version(&self, version: &str) {
207 let allowed = sk::ssh_version_allows_pins(version);
208 if self.ssh_allows_security_key_pins.set(allowed).is_ok() {
209 log::debug!("[purple] security key: PIN hand-out allowed={allowed} ssh={version}");
210 }
211 }
212
213 pub(crate) fn security_key_confirmed_run(&self) -> SecurityKeyRun {
215 if self.security_key_pins.confirmed.is_empty() || !self.security_key_pins_allowed() {
216 return SecurityKeyRun::default();
217 }
218 SecurityKeyRun {
219 pins: self.security_key_pins.pins_for(self.env.paths(), None),
220 flow: None,
221 }
222 }
223
224 pub(crate) fn security_key_run_for(&self, alias: &str, flow: PinFlow) -> SecurityKeyRun {
227 if !self.security_key_pins_allowed() {
228 return SecurityKeyRun::default();
229 }
230 self.security_key_pins
231 .run_for(self.env.paths(), alias, flow)
232 }
233
234 pub(crate) fn settle_security_key_run(
240 &mut self,
241 alias: &str,
242 flow: PinFlow,
243 end: RunEnd,
244 ) -> PinOutcome {
245 let paths = self.env.paths().cloned();
246 let paths = paths.as_ref();
247 let success = end == RunEnd::Connected;
248 let handed_out = sk::take_handed_out_keys(paths, alias);
249 let requested = sk::take_pin_request(paths, flow, alias);
250 if let Some(trial) = self.security_key_pins.take_trial_for(alias, flow) {
251 if let Some(reason) = sk::refusal_for(paths, &trial.key_path) {
252 log::debug!(
253 "[purple] security key: trial PIN refused alias={alias} key={} reason={reason:?}",
254 trial.key_path
255 );
256 return PinOutcome::Rejected {
257 key_path: trial.key_path,
258 reason,
259 connected: success,
260 };
261 }
262 if success && handed_out.contains(&trial.key_path) {
263 log::debug!(
264 "[purple] security key: PIN confirmed alias={alias} key={}",
265 trial.key_path
266 );
267 self.security_key_pins
268 .confirmed
269 .insert(trial.key_path, trial.pin);
270 return PinOutcome::NotInvolved;
271 }
272 log::debug!(
273 "[purple] security key: trial PIN unproven, dropped alias={alias} key={}",
274 trial.key_path
275 );
276 }
277 let refused: Vec<(String, RefusalReason)> = self
278 .security_key_pins
279 .confirmed
280 .keys()
281 .filter_map(|k| sk::refusal_for(paths, k).map(|r| (k.clone(), r)))
282 .collect();
283 let mut reported: Option<(String, RefusalReason)> = None;
287 for (key, reason) in refused {
288 self.security_key_pins.confirmed.remove(&key);
289 if handed_out.contains(&key) {
290 log::debug!("[purple] security key: refused PIN forgotten alias={alias} key={key}");
291 reported.get_or_insert((key, reason));
292 } else {
293 log::debug!(
294 "[purple] security key: PIN refused by another run, forgotten key={key}"
295 );
296 }
297 }
298 if let Some((key_path, reason)) = reported {
299 return PinOutcome::Rejected {
300 key_path,
301 reason,
302 connected: success,
303 };
304 }
305 if end == RunEnd::AuthRefused
306 && let Some(key_path) = requested
307 {
308 return PinOutcome::Needed { key_path };
309 }
310 PinOutcome::NotInvolved
311 }
312
313 pub(crate) fn abandon_security_key_trial(&mut self, flow: PinFlow, alias: Option<&str>) {
316 if self.security_key_pins.drop_trial(flow, alias) {
317 log::debug!(
318 "[purple] security key: trial PIN dropped, run stopped flow={} alias={}",
319 flow.as_str(),
320 alias.unwrap_or("-")
321 );
322 }
323 }
324
325 pub(crate) fn settle_authenticated_tunnel_trial(&mut self) -> Option<(String, PinOutcome)> {
328 let alias = self
329 .security_key_pins
330 .trial()
331 .filter(|t| t.flow == PinFlow::Tunnel)?
332 .alias
333 .clone();
334 let up = self
335 .tunnels
336 .active_get(&alias)
337 .is_some_and(|t| t.live.authenticated.load(Ordering::Relaxed));
338 if !up {
339 return None;
340 }
341 let outcome = self.settle_security_key_run(&alias, PinFlow::Tunnel, RunEnd::Connected);
342 Some((alias, outcome))
343 }
344
345 pub(crate) fn security_key_label(&self, key_path: &str) -> String {
347 match self.env.paths() {
348 Some(paths) => paths.abbreviate_home(std::path::Path::new(key_path)),
349 None => key_path.to_string(),
350 }
351 }
352
353 pub(crate) fn can_open_tunnel_dialog(&self) -> bool {
356 self.can_open_dialog()
357 || (!self.dialog_open()
358 && matches!(self.screen, Screen::TunnelList { .. })
359 && self.tunnels.pending_delete().is_none())
360 }
361
362 pub(crate) fn open_security_key_pin_prompt(
366 &mut self,
367 alias: &str,
368 key_path: &str,
369 retry: PendingRetry,
370 refused: Option<RefusalReason>,
371 prefill: Option<String>,
372 ) {
373 log::debug!(
374 "[purple] security key: PIN prompt open alias={alias} key={key_path} retry={} refused={refused:?}",
375 retry.kind_label()
376 );
377 let mut state = super::PasswordPromptState::security_key_pin(
378 alias.to_string(),
379 key_path.to_string(),
380 retry,
381 );
382 if let Screen::TunnelList { alias: list_alias } = &self.screen {
383 state.return_to_tunnel_list = Some(list_alias.clone());
384 }
385 if let Some(pin) = prefill {
386 state.input = pin;
387 }
388 self.password_prompt = Some(state);
389 self.set_screen(Screen::PasswordPrompt);
390 if let Some(reason) = refused {
391 let label = self.security_key_label(key_path);
392 self.notify_warning(crate::messages::askpass::pin_refused(&label, reason));
393 }
394 }
395}
396
397#[cfg(test)]
398#[path = "security_key_pins_tests.rs"]
399mod tests;