Skip to main content

purple_ssh/app/
security_key_pins.rs

1//! Security key PINs typed in the TUI, held in memory until purple exits.
2//!
3//! A typed PIN first goes out as a trial with the operation that asked for
4//! it. It joins the confirmed set, which every later run may hand out, only
5//! once a run proved it: askpass handed it out, the run succeeded and the key
6//! refused nothing. A wrong PIN costs one attempt per typing, never more.
7
8use 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/// A PIN typed for one key and one operation, not proven yet.
15#[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
23// Hand-written so a stray `{:?}` can never print the PIN.
24impl 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/// Proven PINs by key path, plus the one trial in flight.
35#[derive(Default)]
36pub struct SecurityKeyPins {
37    confirmed: HashMap<String, String>,
38    trial: Option<PinTrial>,
39}
40
41// Hand-written so a stray `{:?}` can never print a PIN.
42impl 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    /// Put a freshly typed PIN on trial. It replaces any earlier trial.
55    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    /// The proven PIN for `key_path`, if there is one.
76    pub fn confirmed_pin(&self, key_path: &str) -> Option<&str> {
77        self.confirmed.get(key_path).map(String::as_str)
78    }
79
80    /// Every proven PIN the key has not refused since, plus the trial when it
81    /// belongs to `trial_for`.
82    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    /// The run for `flow` on `alias`. Callers check the OpenSSH gate first.
111    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    /// Drop the trial of `flow`, only on `alias` when one is given. Returns
124    /// true when one went.
125    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    /// Drop a trial whose host left the config. Returns true when one went.
138    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/// How a background run ended, as far as security key PINs care.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum RunEnd {
154    /// The run got in and did its work.
155    Connected,
156    /// ssh gave up on authentication.
157    AuthRefused,
158    /// Anything else, such as the network or the remote command failing.
159    OtherFailure,
160}
161
162impl RunEnd {
163    /// Classify a run from whether it succeeded and what ssh left on stderr.
164    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/// What a finished run says about security key PINs.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum PinOutcome {
178    NotInvolved,
179    /// The run met a PIN prompt purple had no PIN for.
180    Needed {
181        key_path: String,
182    },
183    /// The key refused the PIN purple handed out. `connected` is true when
184    /// the host let the run in another way.
185    Rejected {
186        key_path: String,
187        reason: RefusalReason,
188        connected: bool,
189    },
190}
191
192impl App {
193    /// True when the installed OpenSSH keeps server prompts apart from PIN
194    /// prompts. Asked once per session.
195    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    /// Seed the gate with the `ssh -V` output read at startup, so the TUI
205    /// never runs it again.
206    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    /// The proven PINs a run without a prompt of its own may hand out.
214    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    /// The PINs plus the flow for a run of `flow` on `alias`, the trial
225    /// included when it belongs to this run.
226    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    /// Settle what a finished run of `flow` on `alias` says about PINs. A
235    /// trial is confirmed or dropped, a proven PIN the key refused is
236    /// forgotten and a PIN the run asked for is reported. A request counts
237    /// only when the login itself was refused; otherwise it goes, so a later
238    /// unrelated failure never opens a PIN prompt.
239    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        // Every refused PIN goes, but only one this alias handed out is this
284        // run's to report. A container or snippet run elsewhere may have
285        // written the refusal.
286        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    /// Drop the trial when the run that carries it was stopped and will never
314    /// settle, so a new run asks for the PIN again instead of reusing it.
315    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    /// Settle a tunnel trial once ssh says the target let the tunnel in. The
326    /// tunnel stays up, so this cannot wait for an exit.
327    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    /// `key_path` the way the user would write it.
346    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    /// True when a tunnel's PIN prompt may open: wherever any dialog may, and
354    /// over the per-host tunnel list the start came from.
355    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    /// Open the PIN prompt for `key_path` with `retry` queued behind it.
363    /// `refused` names why the key turned the last PIN down, which the toast
364    /// reports. `prefill` holds a PIN already proven for this key.
365    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;