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 `ProxyJump` there is
124    /// only one host that could have refused, so the name ssh printed does
125    /// not 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.
128    pub(crate) fn refusal_is_this_host(&self, alias: &str, named: Option<&str>) -> bool {
129        let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
130            // Not in the list, so there is no ProxyJump to read. Treat it as
131            // the plain single-hop case.
132            return true;
133        };
134        if host.proxy_jump.trim().is_empty() {
135            return true;
136        }
137        // Behind a jump host, so the name decides. A refusal naming nobody
138        // could have come from either end.
139        let Some(named) = named else {
140            return false;
141        };
142        let named = crate::connection::bare_host(named);
143        let target = if host.hostname.trim().is_empty() {
144            alias
145        } else {
146            host.hostname.trim()
147        };
148        named.eq_ignore_ascii_case(target)
149    }
150
151    /// True while a dialog is waiting for an answer. A second one must not
152    /// open over it: the retry the first was holding would be lost with no
153    /// trace, and the user would answer a question they never saw asked.
154    pub(crate) fn dialog_open(&self) -> bool {
155        self.password_prompt.is_some()
156            || matches!(
157                self.screen,
158                super::Screen::PasswordPrompt | super::Screen::ConfirmHostKeyTrust { .. }
159            )
160    }
161
162    /// True when a background answer may take the screen: no other dialog is
163    /// waiting, and the user is on a page a dialog returns to rather than
164    /// inside a form, a picker or an overlay. Taking the screen from a form
165    /// would send the keys they are still typing into the password field.
166    /// Anywhere else the question waits until they come back.
167    pub(crate) fn can_open_dialog(&self) -> bool {
168        !self.dialog_open()
169            && matches!(
170                self.screen,
171                super::Screen::HostList | super::Screen::FileBrowser { .. }
172            )
173    }
174
175    /// True when purple already handed `alias` a password and the server
176    /// still refused: either a source resolves for it or a password typed
177    /// this session is on file. The prompt then says so, instead of
178    /// reopening as though nothing had been tried.
179    pub(crate) fn password_was_supplied_for(&self, alias: &str) -> bool {
180        self.askpass_source_for(alias).is_some() || self.session_passwords.contains_key(alias)
181    }
182
183    /// Open the password prompt for `alias` with `retry` queued behind it.
184    /// A password the server just rejected is dropped first, so the retry
185    /// after this prompt carries the new one. `supplied_before` says whether
186    /// an earlier answer was already refused, which the toast reports.
187    pub(crate) fn open_password_prompt(
188        &mut self,
189        alias: &str,
190        retry: PendingRetry,
191        supplied_before: bool,
192    ) {
193        self.session_passwords.remove(alias);
194        let source_is_keychain = self.askpass_source_for(alias).as_deref() == Some("keychain");
195        log::debug!(
196            "[purple] password prompt: open alias={} keychain_source={} supplied_before={} retry={}",
197            alias,
198            source_is_keychain,
199            supplied_before,
200            match &retry {
201                PendingRetry::KeyPush { .. } => "key_push",
202                PendingRetry::FileBrowserListing { .. } => "file_browser",
203            }
204        );
205        self.password_prompt = Some(PasswordPromptState::new(
206            alias.to_string(),
207            source_is_keychain,
208            retry,
209        ));
210        self.set_screen(super::Screen::PasswordPrompt);
211        if supplied_before {
212            self.notify_warning(crate::messages::askpass::password_rejected(alias));
213        }
214    }
215
216    /// The password typed for `alias` this session, if the user chose not to
217    /// store it. Handed to every later background ssh for that host through
218    /// the one-shot askpass channel, so a host is asked once and not once
219    /// per directory.
220    pub(crate) fn session_password_for(&self, alias: &str) -> Option<String> {
221        self.session_passwords.get(alias).cloned()
222    }
223
224    /// Build the SSH context a background operation on `alias` runs with:
225    /// the caller's resolved askpass source, any password typed this
226    /// session, the Bitwarden token and whether a tunnel is already open.
227    /// `trust_new_host_key` is false; only a retry after the trust dialog
228    /// sets it.
229    pub(crate) fn ssh_context_for(
230        &self,
231        alias: impl Into<String>,
232        askpass: Option<String>,
233    ) -> crate::ssh_context::OwnedSshContext {
234        let alias = alias.into();
235        crate::ssh_context::OwnedSshContext {
236            session_password: self.session_password_for(&alias),
237            has_tunnel: self.tunnels.active_contains(&alias),
238            alias,
239            config_path: self.reload.config_path().to_path_buf(),
240            askpass,
241            bw_session: self.bw_session.clone(),
242            trust_new_host_key: false,
243            env: std::sync::Arc::clone(&self.env),
244        }
245    }
246
247    /// Open the trust dialog for `alias` with `retry` queued behind it. The
248    /// dialog names the configured HostName, falling back to the alias.
249    pub(crate) fn open_host_key_trust(&mut self, alias: &str, retry: PendingRetry) {
250        let hostname = self
251            .hosts_state
252            .list
253            .iter()
254            .find(|h| h.alias == alias)
255            .map(|h| h.hostname.clone())
256            .filter(|h| !h.is_empty())
257            .unwrap_or_else(|| alias.to_string());
258        log::debug!(
259            "[purple] host key trust: open alias={} hostname={}",
260            alias,
261            hostname
262        );
263        self.set_screen(super::Screen::ConfirmHostKeyTrust {
264            alias: alias.to_string(),
265            hostname,
266            retry,
267        });
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::app::Screen;
275    use crate::ssh_config::model::SshConfigFile;
276
277    fn app_with(config: &str) -> App {
278        let scratch = tempfile::tempdir().expect("tempdir").keep();
279        let cfg = SshConfigFile {
280            elements: SshConfigFile::parse_content(config),
281            path: scratch.join("config"),
282            crlf: false,
283            bom: false,
284        };
285        App::new(cfg)
286    }
287
288    #[test]
289    fn source_allows_prompt_only_for_none_and_keychain() {
290        assert!(source_allows_prompt(None));
291        assert!(source_allows_prompt(Some("keychain")));
292        for other in [
293            "bw:item",
294            "op://v/i/f",
295            "proton:v/i/f",
296            "pass:x",
297            "vault:s#p",
298            "my-cmd %h",
299        ] {
300            assert!(!source_allows_prompt(Some(other)), "{other}");
301        }
302    }
303
304    #[test]
305    fn field_focus_cycles_between_the_two_fields() {
306        assert_eq!(
307            PasswordPromptField::Password.next(),
308            PasswordPromptField::Remember
309        );
310        assert_eq!(
311            PasswordPromptField::Remember.next(),
312            PasswordPromptField::Password
313        );
314    }
315
316    #[test]
317    fn new_state_defaults_to_remember_on_and_password_focus() {
318        let s = PasswordPromptState::new(
319            "h".into(),
320            false,
321            PendingRetry::KeyPush {
322                key_path: "~/.ssh/id_test".into(),
323                alias: "h".into(),
324            },
325        );
326        assert!(s.remember);
327        assert_eq!(s.focus, PasswordPromptField::Password);
328        assert!(s.input.is_empty());
329    }
330
331    #[test]
332    fn pending_retry_alias_names_the_host() {
333        let a = PendingRetry::KeyPush {
334            key_path: "~/.ssh/id_test".into(),
335            alias: "web".into(),
336        };
337        let b = PendingRetry::FileBrowserListing {
338            alias: "db".into(),
339            path: "/".into(),
340        };
341        assert_eq!(a.alias(), "web");
342        assert_eq!(b.alias(), "db");
343    }
344
345    #[test]
346    fn askpass_source_prefers_the_host_comment() {
347        let app = app_with("Host h\n  HostName 1.1.1.1\n  # purple:askpass bw:item\n");
348        assert_eq!(app.askpass_source_for("h").as_deref(), Some("bw:item"));
349        assert_eq!(app.askpass_source_for("missing"), None);
350    }
351
352    #[test]
353    fn askpass_source_falls_back_to_the_global_default() {
354        let app = app_with("Host h\n  HostName 1.1.1.1\n");
355        crate::preferences::save_askpass_default(app.env().paths(), "keychain").unwrap();
356        assert_eq!(app.askpass_source_for("h").as_deref(), Some("keychain"));
357    }
358
359    #[test]
360    fn open_password_prompt_sets_state_screen_and_drops_rejected_password() {
361        let mut app = app_with("Host h\n  HostName 1.1.1.1\n  # purple:askpass keychain\n");
362        app.session_passwords
363            .insert("h".to_string(), "wrong".to_string());
364        let retry = PendingRetry::KeyPush {
365            key_path: "~/.ssh/id_test".into(),
366            alias: "h".into(),
367        };
368        app.open_password_prompt("h", retry.clone(), false);
369        assert_eq!(app.screen, Screen::PasswordPrompt);
370        let state = app.password_prompt.as_ref().expect("prompt state");
371        assert_eq!(state.alias, "h");
372        assert!(state.source_is_keychain);
373        assert_eq!(state.retry, retry);
374        assert!(
375            !app.session_passwords.contains_key("h"),
376            "a rejected session password must not survive into the retry"
377        );
378    }
379
380    #[test]
381    fn a_second_dialog_is_seen_as_already_open() {
382        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
383        assert!(!app.dialog_open());
384        app.open_password_prompt(
385            "h",
386            PendingRetry::KeyPush {
387                key_path: "~/.ssh/id_test".into(),
388                alias: "h".into(),
389            },
390            false,
391        );
392        assert!(app.dialog_open());
393        app.password_prompt = None;
394        app.screen = Screen::HostList;
395        app.open_host_key_trust(
396            "h",
397            PendingRetry::FileBrowserListing {
398                alias: "h".into(),
399                path: "/".into(),
400            },
401        );
402        assert!(app.dialog_open());
403    }
404
405    #[test]
406    fn a_reopened_prompt_says_the_last_password_was_refused() {
407        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
408        app.session_passwords
409            .insert("h".to_string(), "wrong".to_string());
410        assert!(app.password_was_supplied_for("h"));
411        let supplied = app.password_was_supplied_for("h");
412        app.open_password_prompt(
413            "h",
414            PendingRetry::KeyPush {
415                key_path: "~/.ssh/id_test".into(),
416                alias: "h".into(),
417            },
418            supplied,
419        );
420        let toast = app.status_center.toast().expect("toast");
421        assert!(toast.text.contains('h'), "got: {}", toast.text);
422        // The refused password is gone, so the retry carries the new one.
423        assert!(!app.session_passwords.contains_key("h"));
424    }
425
426    #[test]
427    fn a_keychain_source_counts_as_a_password_already_supplied() {
428        let app = app_with("Host h\n  HostName 1.1.1.1\n  # purple:askpass keychain\n");
429        assert!(app.password_was_supplied_for("h"));
430        let bare = app_with("Host h\n  HostName 1.1.1.1\n");
431        assert!(!bare.password_was_supplied_for("h"));
432    }
433
434    #[test]
435    fn ssh_context_for_carries_the_session_password_and_defaults_to_strict_checking() {
436        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
437        app.session_passwords
438            .insert("h".to_string(), "hunter2".to_string());
439        let ctx = app.ssh_context_for("h", Some("keychain".to_string()));
440        assert_eq!(ctx.alias, "h");
441        assert_eq!(ctx.askpass.as_deref(), Some("keychain"));
442        assert_eq!(ctx.session_password.as_deref(), Some("hunter2"));
443        assert!(!ctx.trust_new_host_key);
444        assert!(!ctx.has_tunnel);
445    }
446
447    #[test]
448    fn ssh_context_for_leaves_the_password_empty_for_another_host() {
449        let mut app = app_with("Host h\n  HostName 1.1.1.1\n");
450        app.session_passwords
451            .insert("other".to_string(), "hunter2".to_string());
452        assert!(app.ssh_context_for("h", None).session_password.is_none());
453    }
454
455    #[test]
456    fn open_host_key_trust_names_the_hostname_and_falls_back_to_alias() {
457        let mut app = app_with("Host h\n  HostName db.example.com\n");
458        let retry = PendingRetry::FileBrowserListing {
459            alias: "h".into(),
460            path: String::new(),
461        };
462        app.open_host_key_trust("h", retry.clone());
463        assert_eq!(
464            app.screen,
465            Screen::ConfirmHostKeyTrust {
466                alias: "h".into(),
467                hostname: "db.example.com".into(),
468                retry: retry.clone(),
469            }
470        );
471        app.open_host_key_trust("ghost", retry);
472        match &app.screen {
473            Screen::ConfirmHostKeyTrust { hostname, .. } => assert_eq!(hostname, "ghost"),
474            other => panic!("expected trust dialog, got {other:?}"),
475        }
476    }
477}