Skip to main content

purple_ssh/app/
password_prompt.rs

1//! State behind the in-TUI password prompt and the host key trust dialog.
2//!
3//! A background ssh (key push, file browser listing) can never reach the
4//! terminal, so a host that wants a password or is not in `known_hosts`
5//! yet asks through the TUI instead. Both dialogs carry a `PendingRetry`
6//! that names the operation to run again once the user has answered.
7
8use std::collections::HashMap;
9
10use super::App;
11
12/// Longest password the prompt accepts. Far above any real password and
13/// keeps a held-down key from growing the buffer without bound.
14pub const PASSWORD_MAX_CHARS: usize = 512;
15
16/// The operation to run again after the user answered a dialog. One
17/// variant per flow; each re-queues through the channel that flow owns.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum PendingRetry {
20    /// Push the key at `key_path` to this one host again. The key is named
21    /// by its path, not its position: a successful push rebuilds the key
22    /// list, and an index into the old list could then point at another key.
23    KeyPush { key_path: String, alias: String },
24    /// List `path` on the open file browser again. An empty path means the
25    /// remote home lookup itself failed and runs first.
26    FileBrowserListing { alias: String, path: String },
27}
28
29impl PendingRetry {
30    pub fn alias(&self) -> &str {
31        match self {
32            PendingRetry::KeyPush { alias, .. }
33            | PendingRetry::FileBrowserListing { alias, .. } => alias,
34        }
35    }
36}
37
38/// Which field of the password prompt has focus.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum PasswordPromptField {
41    #[default]
42    Password,
43    Remember,
44}
45
46impl PasswordPromptField {
47    pub fn next(self) -> Self {
48        match self {
49            PasswordPromptField::Password => PasswordPromptField::Remember,
50            PasswordPromptField::Remember => PasswordPromptField::Password,
51        }
52    }
53}
54
55/// Payload of `Screen::PasswordPrompt`. Lives beside the screen instead of
56/// inside the variant so the secret is never cloned on every render.
57#[derive(Clone, PartialEq, Eq)]
58pub struct PasswordPromptState {
59    pub alias: String,
60    /// True when the alias already resolves to `keychain`, so a "remember"
61    /// submit stores the password without writing the config again.
62    pub source_is_keychain: bool,
63    pub input: String,
64    pub remember: bool,
65    pub focus: PasswordPromptField,
66    pub retry: PendingRetry,
67}
68
69// Hand-written so a stray `{:?}` can never print the typed password. Shows
70// whether one has been entered, never what it is.
71impl std::fmt::Debug for PasswordPromptState {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct("PasswordPromptState")
74            .field("alias", &self.alias)
75            .field("source_is_keychain", &self.source_is_keychain)
76            .field("input_len", &self.input.chars().count())
77            .field("remember", &self.remember)
78            .field("focus", &self.focus)
79            .field("retry", &self.retry)
80            .finish()
81    }
82}
83
84impl PasswordPromptState {
85    pub fn new(alias: String, source_is_keychain: bool, retry: PendingRetry) -> Self {
86        Self {
87            alias,
88            source_is_keychain,
89            input: String::new(),
90            remember: true,
91            focus: PasswordPromptField::Password,
92            retry,
93        }
94    }
95}
96
97/// Passwords typed with "remember" off, keyed by alias, kept until purple
98/// exits. Every later background operation on the alias uses the entry
99/// through the one-shot askpass channel. Nothing here reaches disk.
100pub type SessionPasswords = HashMap<String, String>;
101
102/// True when a password prompt may open for a host with this source: no
103/// source at all, or the OS keychain. Any other source failing means the
104/// source itself did not deliver, which a typed password cannot fix.
105pub fn source_allows_prompt(source: Option<&str>) -> bool {
106    matches!(source, None | Some("keychain"))
107}
108
109impl App {
110    /// The password source a background ssh for `alias` resolves to: the
111    /// host's own `# purple:askpass`, else the global default. Mirrors the
112    /// lookup the askpass subprocess performs.
113    pub(crate) fn askpass_source_for(&self, alias: &str) -> Option<String> {
114        self.hosts_state
115            .list
116            .iter()
117            .find(|h| h.alias == alias)
118            .and_then(|h| h.askpass.clone())
119            .or_else(|| crate::preferences::load_askpass_default(self.env.paths()))
120    }
121
122    /// True when ssh's refusal can be attributed to `alias` itself rather
123    /// than to a jump host on the way there. Without a bastion there is only
124    /// one host that could have refused, so the name ssh printed does not
125    /// matter. With one, ssh names the hop that refused and only the
126    /// target's own refusal is ours to answer: a dialog for a bastion would
127    /// write that bastion's password onto the target. A `ProxyCommand`
128    /// counts as a bastion as much as a `ProxyJump` does, since it reaches
129    /// a machine of its own that purple cannot name.
130    pub(crate) fn refusal_is_this_host(&self, alias: &str, named: Option<&str>) -> bool {
131        let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
132            // Not in the list, so there is no route to read. Treat it as
133            // the plain single-hop case.
134            return true;
135        };
136        if host.proxy_jump.trim().is_empty() && !host.has_proxy_command {
137            return true;
138        }
139        // Behind a jump host, so the name decides. A refusal naming nobody
140        // could have come from either end.
141        let Some(named) = named else {
142            return false;
143        };
144        let named = crate::connection::bare_host(named);
145        let target = if host.hostname.trim().is_empty() {
146            alias
147        } else {
148            host.hostname.trim()
149        };
150        named.eq_ignore_ascii_case(target)
151    }
152
153    /// True while a dialog is waiting for an answer. A second one must not
154    /// open over it: the retry the first was holding would be lost with no
155    /// trace, and the user would answer a question they never saw asked.
156    pub(crate) fn dialog_open(&self) -> bool {
157        self.password_prompt.is_some()
158            || matches!(
159                self.screen,
160                super::Screen::PasswordPrompt | super::Screen::ConfirmHostKeyTrust { .. }
161            )
162    }
163
164    /// True when a background answer may take the screen. No other dialog
165    /// may be waiting. The user has to be on a page a dialog returns to
166    /// rather than inside a form, a picker or an overlay. Every typing mode
167    /// that lives inside those pages has to be closed as well: the host
168    /// list's search, its tag field and the jump bar. None of the three
169    /// changes `self.screen`, so the screen alone does not rule them out,
170    /// and the jump bar even takes every key ahead of the screen match.
171    /// Opening over one of them would send the rest of what they type into
172    /// the password field, and the Enter that ends a search would submit
173    /// the fragment as a password. Anywhere else the question waits until
174    /// they come back.
175    pub(crate) fn can_open_dialog(&self) -> bool {
176        !self.dialog_open()
177            && self.search.query().is_none()
178            && self.tags.input().is_none()
179            && self.jump.is_none()
180            && self.tunnels.pending_delete().is_none()
181            && self.snippets.pending_delete().is_none()
182            && !self.file_browser_is_busy()
183            && matches!(
184                self.screen,
185                super::Screen::HostList | super::Screen::FileBrowser { .. }
186            )
187    }
188
189    /// True while the file browser has a question or a transfer of its own
190    /// on screen. All three render inside `Screen::FileBrowser`, so a
191    /// dialog opening over them would take the answer meant for them.
192    fn file_browser_is_busy(&self) -> bool {
193        self.file_browser_session.as_ref().is_some_and(|fb| {
194            fb.confirm_copy.is_some() || fb.transferring.is_some() || fb.transfer_error.is_some()
195        })
196    }
197
198    /// Drop a prompt whose screen moved on without it. `dialog_open` reads
199    /// the state rather than the screen, so a stranded one would refuse
200    /// every later question for the rest of the session. Returns true when
201    /// one was cleared.
202    pub(crate) fn drop_stranded_password_prompt(&mut self) -> bool {
203        if self.password_prompt.is_none() || matches!(self.screen, super::Screen::PasswordPrompt) {
204            return false;
205        }
206        let alias = self
207            .password_prompt
208            .take()
209            .map(|s| s.alias)
210            .unwrap_or_default();
211        log::warn!("[purple] password prompt: dropped stranded state for alias={alias}");
212        self.notify_warning(crate::messages::askpass::prompt_cancelled(&alias));
213        true
214    }
215
216    /// True when purple already handed `alias` a password and the server
217    /// still refused: either a source resolves for it or a password typed
218    /// this session is on file. The prompt then says so, instead of
219    /// reopening as though nothing had been tried.
220    pub(crate) fn password_was_supplied_for(&self, alias: &str) -> bool {
221        self.askpass_source_for(alias).is_some() || self.session_passwords.contains_key(alias)
222    }
223
224    /// Open the password prompt for `alias` with `retry` queued behind it.
225    /// A password the server just rejected is dropped first, so the retry
226    /// after this prompt carries the new one. `supplied_before` says whether
227    /// an earlier answer was already refused, which the toast reports.
228    pub(crate) fn open_password_prompt(
229        &mut self,
230        alias: &str,
231        retry: PendingRetry,
232        supplied_before: bool,
233    ) {
234        self.session_passwords.remove(alias);
235        let source_is_keychain = self.askpass_source_for(alias).as_deref() == Some("keychain");
236        log::debug!(
237            "[purple] password prompt: open alias={} keychain_source={} supplied_before={} retry={}",
238            alias,
239            source_is_keychain,
240            supplied_before,
241            match &retry {
242                PendingRetry::KeyPush { .. } => "key_push",
243                PendingRetry::FileBrowserListing { .. } => "file_browser",
244            }
245        );
246        self.password_prompt = Some(PasswordPromptState::new(
247            alias.to_string(),
248            source_is_keychain,
249            retry,
250        ));
251        self.set_screen(super::Screen::PasswordPrompt);
252        if supplied_before {
253            self.notify_warning(crate::messages::askpass::password_rejected(alias));
254        }
255    }
256
257    /// The password typed for `alias` this session, if the user chose not to
258    /// store it. Handed to every later background ssh for that host through
259    /// the one-shot askpass channel, so a host is asked once and not once
260    /// per directory.
261    pub(crate) fn session_password_for(&self, alias: &str) -> Option<String> {
262        self.session_passwords.get(alias).cloned()
263    }
264
265    /// Build the SSH context a background operation on `alias` runs with:
266    /// the caller's resolved askpass source, any password typed this
267    /// session, the Bitwarden token and whether a tunnel is already open.
268    /// `trust_new_host_key` is false; only a retry after the trust dialog
269    /// sets it.
270    pub(crate) fn ssh_context_for(
271        &self,
272        alias: impl Into<String>,
273        askpass: Option<String>,
274    ) -> crate::ssh_context::OwnedSshContext {
275        let alias = alias.into();
276        crate::ssh_context::OwnedSshContext {
277            session_password: self.session_password_for(&alias),
278            has_tunnel: self.tunnels.active_contains(&alias),
279            alias,
280            config_path: self.reload.config_path().to_path_buf(),
281            askpass,
282            bw_session: self.bw_session.clone(),
283            trust_new_host_key: false,
284            env: std::sync::Arc::clone(&self.env),
285        }
286    }
287
288    /// Open the trust dialog for `alias` with `retry` queued behind it. The
289    /// dialog names the configured HostName, falling back to the alias.
290    pub(crate) fn open_host_key_trust(&mut self, alias: &str, retry: PendingRetry) {
291        let hostname = self
292            .hosts_state
293            .list
294            .iter()
295            .find(|h| h.alias == alias)
296            .map(|h| h.hostname.clone())
297            .filter(|h| !h.is_empty())
298            .unwrap_or_else(|| alias.to_string());
299        log::debug!(
300            "[purple] host key trust: open alias={} hostname={}",
301            alias,
302            hostname
303        );
304        self.set_screen(super::Screen::ConfirmHostKeyTrust {
305            alias: alias.to_string(),
306            hostname,
307            retry,
308        });
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::app::Screen;
316    use crate::ssh_config::model::SshConfigFile;
317
318    fn app_with(config: &str) -> App {
319        let scratch = tempfile::tempdir().expect("tempdir").keep();
320        let cfg = SshConfigFile {
321            elements: SshConfigFile::parse_content(config),
322            path: scratch.join("config"),
323            crlf: false,
324            bom: false,
325        };
326        App::new(cfg)
327    }
328
329    #[test]
330    fn source_allows_prompt_only_for_none_and_keychain() {
331        assert!(source_allows_prompt(None));
332        assert!(source_allows_prompt(Some("keychain")));
333        for other in [
334            "bw:item",
335            "op://v/i/f",
336            "proton:v/i/f",
337            "pass:x",
338            "vault:s#p",
339            "my-cmd %h",
340        ] {
341            assert!(!source_allows_prompt(Some(other)), "{other}");
342        }
343    }
344
345    #[test]
346    fn field_focus_cycles_between_the_two_fields() {
347        assert_eq!(
348            PasswordPromptField::Password.next(),
349            PasswordPromptField::Remember
350        );
351        assert_eq!(
352            PasswordPromptField::Remember.next(),
353            PasswordPromptField::Password
354        );
355    }
356
357    #[test]
358    fn new_state_defaults_to_remember_on_and_password_focus() {
359        let s = PasswordPromptState::new(
360            "h".into(),
361            false,
362            PendingRetry::KeyPush {
363                key_path: "~/.ssh/id_test".into(),
364                alias: "h".into(),
365            },
366        );
367        assert!(s.remember);
368        assert_eq!(s.focus, PasswordPromptField::Password);
369        assert!(s.input.is_empty());
370    }
371
372    #[test]
373    fn pending_retry_alias_names_the_host() {
374        let a = PendingRetry::KeyPush {
375            key_path: "~/.ssh/id_test".into(),
376            alias: "web".into(),
377        };
378        let b = PendingRetry::FileBrowserListing {
379            alias: "db".into(),
380            path: "/".into(),
381        };
382        assert_eq!(a.alias(), "web");
383        assert_eq!(b.alias(), "db");
384    }
385
386    #[test]
387    fn askpass_source_prefers_the_host_comment() {
388        let app = app_with("Host h\n  HostName 1.1.1.1\n  # purple:askpass bw:item\n");
389        assert_eq!(app.askpass_source_for("h").as_deref(), Some("bw:item"));
390        assert_eq!(app.askpass_source_for("missing"), None);
391    }
392
393    #[test]
394    fn askpass_source_falls_back_to_the_global_default() {
395        let app = app_with("Host h\n  HostName 1.1.1.1\n");
396        crate::preferences::save_askpass_default(app.env().paths(), "keychain").unwrap();
397        assert_eq!(app.askpass_source_for("h").as_deref(), Some("keychain"));
398    }
399
400    #[test]
401    fn open_password_prompt_sets_state_screen_and_drops_rejected_password() {
402        let mut app = app_with("Host h\n  HostName 1.1.1.1\n  # purple:askpass keychain\n");
403        app.session_passwords
404            .insert("h".to_string(), "wrong".to_string());
405        let retry = PendingRetry::KeyPush {
406            key_path: "~/.ssh/id_test".into(),
407            alias: "h".into(),
408        };
409        app.open_password_prompt("h", retry.clone(), false);
410        assert_eq!(app.screen, Screen::PasswordPrompt);
411        let state = app.password_prompt.as_ref().expect("prompt state");
412        assert_eq!(state.alias, "h");
413        assert!(state.source_is_keychain);
414        assert_eq!(state.retry, retry);
415        assert!(
416            !app.session_passwords.contains_key("h"),
417            "a rejected session password must not survive into the retry"
418        );
419    }
420
421    #[test]
422    fn a_second_dialog_is_seen_as_already_open() {
423        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
424        assert!(!app.dialog_open());
425        app.open_password_prompt(
426            "h",
427            PendingRetry::KeyPush {
428                key_path: "~/.ssh/id_test".into(),
429                alias: "h".into(),
430            },
431            false,
432        );
433        assert!(app.dialog_open());
434        app.password_prompt = None;
435        app.screen = Screen::HostList;
436        app.open_host_key_trust(
437            "h",
438            PendingRetry::FileBrowserListing {
439                alias: "h".into(),
440                path: "/".into(),
441            },
442        );
443        assert!(app.dialog_open());
444    }
445
446    #[test]
447    fn a_reopened_prompt_says_the_last_password_was_refused() {
448        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
449        app.session_passwords
450            .insert("h".to_string(), "wrong".to_string());
451        assert!(app.password_was_supplied_for("h"));
452        let supplied = app.password_was_supplied_for("h");
453        app.open_password_prompt(
454            "h",
455            PendingRetry::KeyPush {
456                key_path: "~/.ssh/id_test".into(),
457                alias: "h".into(),
458            },
459            supplied,
460        );
461        let toast = app.status_center.toast().expect("toast");
462        assert!(toast.text.contains('h'), "got: {}", toast.text);
463        // The refused password is gone, so the retry carries the new one.
464        assert!(!app.session_passwords.contains_key("h"));
465    }
466
467    #[test]
468    fn a_keychain_source_counts_as_a_password_already_supplied() {
469        let app = app_with("Host h\n  HostName 1.1.1.1\n  # purple:askpass keychain\n");
470        assert!(app.password_was_supplied_for("h"));
471        let bare = app_with("Host h\n  HostName 1.1.1.1\n");
472        assert!(!bare.password_was_supplied_for("h"));
473    }
474
475    #[test]
476    fn ssh_context_for_carries_the_session_password_and_defaults_to_strict_checking() {
477        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
478        app.session_passwords
479            .insert("h".to_string(), "hunter2".to_string());
480        let ctx = app.ssh_context_for("h", Some("keychain".to_string()));
481        assert_eq!(ctx.alias, "h");
482        assert_eq!(ctx.askpass.as_deref(), Some("keychain"));
483        assert_eq!(ctx.session_password.as_deref(), Some("hunter2"));
484        assert!(!ctx.trust_new_host_key);
485        assert!(!ctx.has_tunnel);
486    }
487
488    #[test]
489    fn ssh_context_for_leaves_the_password_empty_for_another_host() {
490        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
491        app.session_passwords
492            .insert("other".to_string(), "hunter2".to_string());
493        assert!(app.ssh_context_for("h", None).session_password.is_none());
494    }
495
496    #[test]
497    fn open_host_key_trust_names_the_hostname_and_falls_back_to_alias() {
498        let mut app = app_with("Host h\n  HostName db.example.com\n");
499        let retry = PendingRetry::FileBrowserListing {
500            alias: "h".into(),
501            path: String::new(),
502        };
503        app.open_host_key_trust("h", retry.clone());
504        assert_eq!(
505            app.screen,
506            Screen::ConfirmHostKeyTrust {
507                alias: "h".into(),
508                hostname: "db.example.com".into(),
509                retry: retry.clone(),
510            }
511        );
512        app.open_host_key_trust("ghost", retry);
513        match &app.screen {
514            Screen::ConfirmHostKeyTrust { hostname, .. } => assert_eq!(hostname, "ghost"),
515            other => panic!("expected trust dialog, got {other:?}"),
516        }
517    }
518
519    #[test]
520    fn a_proxy_command_counts_as_a_bastion_in_front_of_the_host() {
521        // `ProxyCommand ssh -W %h:%p bastion` reaches a second machine just
522        // as `ProxyJump bastion` does. A refusal naming nobody could have
523        // come from either end, so it is not this host's to answer.
524        let app =
525            app_with("Host h\n  HostName db.example.com\n  ProxyCommand ssh -W %h:%p bastion\n");
526        assert!(!app.refusal_is_this_host("h", None));
527        assert!(!app.refusal_is_this_host("h", Some("bastion.example.com")));
528        assert!(app.refusal_is_this_host("h", Some("db.example.com")));
529    }
530
531    #[test]
532    fn a_proxy_command_set_to_none_leaves_the_host_direct() {
533        // `ProxyCommand none` is how a wildcard block is opted out of, so it
534        // puts no machine in front of this host.
535        let app = app_with("Host h\n  HostName db.example.com\n  ProxyCommand none\n");
536        assert!(app.refusal_is_this_host("h", None));
537    }
538
539    #[test]
540    fn a_dialog_waits_while_the_host_list_search_is_open() {
541        // The search input lives inside Screen::HostList, so the screen
542        // alone does not say the user is free to be asked a question.
543        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
544        app.screen = Screen::HostList;
545        assert!(app.can_open_dialog());
546        app.search.set_query(Some("web".to_string()));
547        assert!(
548            !app.can_open_dialog(),
549            "a half typed search must keep the screen"
550        );
551        app.search.set_query(None);
552        assert!(app.can_open_dialog());
553    }
554
555    #[test]
556    fn a_dialog_waits_while_the_jump_bar_is_open() {
557        // The jump bar takes every key ahead of the screen match, so a
558        // dialog opening under it would collect what the user types at it.
559        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
560        app.screen = Screen::HostList;
561        app.open_jump(crate::app::JumpMode::Hosts);
562        assert!(!app.can_open_dialog(), "the jump bar keeps the screen");
563    }
564
565    #[test]
566    fn a_dialog_waits_while_another_confirm_is_on_screen() {
567        // A tunnel or snippet delete confirm renders inside the host list
568        // and answers with y or n. Those keys must not reach a password
569        // field that appeared under them.
570        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
571        app.screen = Screen::HostList;
572        app.tunnels.pending_delete = Some(0);
573        assert!(!app.can_open_dialog(), "a tunnel confirm keeps the screen");
574        app.tunnels.take_pending_delete();
575        assert!(app.can_open_dialog());
576
577        app.snippets.pending_delete = Some(0);
578        assert!(!app.can_open_dialog(), "a snippet confirm keeps the screen");
579        app.snippets.pending_delete = None;
580        assert!(app.can_open_dialog());
581    }
582
583    #[test]
584    fn a_dialog_waits_while_the_tag_input_is_open() {
585        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
586        app.screen = Screen::HostList;
587        app.tags.open_tag_input("web".to_string());
588        assert!(
589            !app.can_open_dialog(),
590            "a half typed tag must keep the screen"
591        );
592    }
593
594    #[test]
595    fn a_refusal_is_parsed_out_of_stderr_before_it_is_attributed() {
596        // The seam: what ssh printed, through the parser, into the decision
597        // whether this host may be asked for a password.
598        let app = app_with("Host h\n  HostName target.example.com\n  ProxyJump bastion\n");
599        let bastion = "ops@bastion.example.com: Permission denied (publickey,password).\n";
600        let named = crate::connection::denied_host(bastion);
601        assert_eq!(named, Some("bastion.example.com"));
602        assert!(
603            !app.refusal_is_this_host("h", named),
604            "the jump host's refusal is not the target's to answer"
605        );
606
607        let target = "ops@target.example.com: Permission denied (publickey,password).\n";
608        let named = crate::connection::denied_host(target);
609        assert!(app.refusal_is_this_host("h", named));
610    }
611
612    #[test]
613    fn a_bracketed_host_from_the_trust_line_matches_the_target() {
614        let app = app_with("Host h\n  HostName 10.0.0.1\n  ProxyJump bastion\n");
615        let stderr = "No ED25519 host key is known for [10.0.0.1]:2222 and you have requested strict checking.\n";
616        let named = crate::connection::unknown_host_key_host(stderr);
617        assert_eq!(named, Some("[10.0.0.1]:2222"));
618        assert!(app.refusal_is_this_host("h", named));
619    }
620}