Skip to main content

badterm_core/
transport.rs

1//! Network transports. Each connection runs on its own background thread
2//! and reports [`NetEvent`]s through a callback; the caller writes through
3//! the [`Writer`] handed over in [`NetEvent::Connected`].
4//!
5//! - Telnet, raw and RLogin connections use a plain TCP socket; RLogin
6//!   first sends its login record (see [`rlogin_handshake`]).
7//! - Telnet over TLS: see [`crate::tls`].
8//! - SSH runs the `russh` client on a single-threaded Tokio runtime, opening
9//!   one interactive shell channel with a PTY. Host keys are trusted on first
10//!   use and pinned in BadTerm's own `known_hosts` file; a changed key
11//!   refuses the connection. Login tries "none", then public keys (the
12//!   entry's key file, the SSH agent, the usual `~/.ssh/id_*` files), then
13//!   keyboard-interactive or password, asking the user through
14//!   [`NetEvent::Prompt`] for anything not stored.
15
16use std::fmt;
17use std::io::{self, Read, Write};
18use std::net::{Shutdown, TcpStream, ToSocketAddrs};
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::thread;
22use std::time::Duration;
23
24use anyhow::{Context, Result, anyhow, bail};
25use russh::MethodKind;
26use russh::client::{self, AuthResult, KeyboardInteractiveAuthResponse};
27use russh::keys::agent::AgentIdentity;
28use russh::keys::agent::client::{AgentClient, AgentStream};
29use russh::keys::known_hosts::{check_known_hosts_path, learn_known_hosts_path};
30use russh::keys::{HashAlg, PrivateKey, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate};
31use russh::{ChannelMsg, Disconnect};
32use tokio::sync::{mpsc, oneshot};
33
34use crate::dialer::{Entry, Protocol};
35
36pub(crate) const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
37/// Rounds of keyboard-interactive prompts answered before giving up.
38const MAX_KBD_INTERACTIVE_ROUNDS: usize = 5;
39/// Times the user is asked for a password (or passphrase) before giving up.
40const MAX_PROMPTED_ATTEMPTS: usize = 3;
41/// Key files tried from the SSH directory, in OpenSSH's order.
42const DEFAULT_KEY_FILES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
43const LOGIN_CANCELLED: &str = "Login cancelled";
44
45/// Events produced by a connection thread.
46#[derive(Debug)]
47pub enum NetEvent {
48    /// Connected; carries the handle used for writing.
49    Connected(Writer),
50    Data(Vec<u8>),
51    /// Something worth telling the user, e.g. a newly trusted host key.
52    Notice(String),
53    /// The login needs an answer from the user.
54    Prompt(AuthPrompt),
55    /// The connection ended, with an error message if it failed.
56    Closed(Option<String>),
57}
58
59/// What an [`AuthPrompt`] asks for.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum PromptKind {
62    /// The account password (worth offering to remember).
63    Password,
64    /// The passphrase of a private key file.
65    Passphrase,
66    /// A question from the server's keyboard-interactive login, such as a
67    /// one-time code.
68    Interactive,
69}
70
71/// A question the SSH login needs the user to answer. Answer it with
72/// [`AuthPrompt::answer`]; dropping it unanswered cancels the login.
73pub struct AuthPrompt {
74    pub kind: PromptKind,
75    /// A heading, e.g. "SSH login to bbs.example.com".
76    pub title: String,
77    /// Extra text from the server, or why the question is asked again.
78    pub instructions: String,
79    pub prompt: String,
80    /// Whether the answer may be shown as it is typed.
81    pub echo: bool,
82    reply: oneshot::Sender<Option<String>>,
83}
84
85impl AuthPrompt {
86    /// Reply with the answer, or `None` to cancel the login.
87    pub fn answer(self, answer: Option<String>) {
88        let _ = self.reply.send(answer);
89    }
90}
91
92impl fmt::Debug for AuthPrompt {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.debug_struct("AuthPrompt")
95            .field("kind", &self.kind)
96            .field("title", &self.title)
97            .field("instructions", &self.instructions)
98            .field("prompt", &self.prompt)
99            .field("echo", &self.echo)
100            .finish()
101    }
102}
103
104/// Settings for establishing a connection.
105#[derive(Clone, Debug)]
106pub struct ConnectOptions {
107    pub cols: u16,
108    pub rows: u16,
109    /// Where SSH host keys are pinned.
110    pub known_hosts: PathBuf,
111    /// Whether to offer the SSH agent's keys.
112    pub ssh_agent: bool,
113    /// Where to look for default SSH keys (usually `~/.ssh`).
114    pub ssh_key_dir: Option<PathBuf>,
115    /// The modem, for modem entries.
116    pub modem: crate::modem::ModemConfig,
117}
118
119/// What a [`Writer`] asks a connection task (SSH or TLS) to do.
120pub(crate) enum Cmd {
121    Data(Vec<u8>),
122    Close,
123}
124
125/// The sending side of a connection.
126pub struct Writer(pub(crate) WriterKind);
127
128pub(crate) enum WriterKind {
129    Tcp(TcpStream),
130    /// To a task that owns the connection (SSH, TLS).
131    Channel(mpsc::UnboundedSender<Cmd>),
132    /// A modem: the port, and the flag that tells its thread to hang up.
133    Serial(Mutex<Box<dyn serialport::SerialPort>>, Arc<std::sync::atomic::AtomicBool>),
134}
135
136impl fmt::Debug for Writer {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        f.write_str(match self.0 {
139            WriterKind::Tcp(_) => "Writer(TCP)",
140            WriterKind::Channel(_) => "Writer(channel)",
141            WriterKind::Serial(..) => "Writer(modem)",
142        })
143    }
144}
145
146impl Writer {
147    pub fn write_all(&mut self, data: &[u8]) -> io::Result<()> {
148        match &mut self.0 {
149            WriterKind::Tcp(s) => s.write_all(data),
150            WriterKind::Channel(tx) => tx
151                .send(Cmd::Data(data.to_vec()))
152                .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "connection closed")),
153            WriterKind::Serial(port, _) => crate::modem::write(port, data),
154        }
155    }
156
157    /// Close the connection; the reading thread then reports `Closed`.
158    pub fn shutdown(&self) {
159        match &self.0 {
160            WriterKind::Tcp(s) => {
161                let _ = s.shutdown(Shutdown::Both);
162            }
163            WriterKind::Channel(tx) => {
164                let _ = tx.send(Cmd::Close);
165            }
166            WriterKind::Serial(_, stop) => stop.store(true, std::sync::atomic::Ordering::Relaxed),
167        }
168    }
169}
170
171/// Connect to `entry` in a background thread, reporting through `sink`.
172///
173/// `sink` is called from the background thread; the GUI uses it to forward
174/// events into its event loop.
175pub fn spawn_connection<F>(entry: &Entry, opts: &ConnectOptions, sink: F)
176where
177    F: Fn(NetEvent) + Send + 'static,
178{
179    match entry.protocol {
180        Protocol::Telnet | Protocol::Raw => spawn_tcp(entry.host.clone(), entry.port, None, sink),
181        Protocol::Rlogin => spawn_tcp(entry.host.clone(), entry.port, Some(rlogin_handshake(entry)), sink),
182        Protocol::TelnetS => {
183            crate::tls::spawn_tls(entry.host.clone(), entry.port, tls_pins_path(&opts.known_hosts), sink)
184        }
185        Protocol::Ssh => {
186            let params = SshParams {
187                host: entry.host.clone(),
188                port: entry.port,
189                user: entry.username.clone(),
190                password: entry.password.clone(),
191                key_file: (!entry.key_file.trim().is_empty()).then(|| expand_home(entry.key_file.trim())),
192                agent: opts.ssh_agent,
193                key_dir: opts.ssh_key_dir.clone(),
194                term: if entry.term_type.is_empty() { "ansi".into() } else { entry.term_type.clone() },
195                cols: opts.cols,
196                rows: opts.rows,
197                known_hosts: opts.known_hosts.clone(),
198            };
199            spawn_ssh(params, sink);
200        }
201        Protocol::Modem => crate::modem::spawn_modem(entry.host.clone(), opts.modem.clone(), sink),
202    }
203}
204
205/// Where TLS certificates are pinned: beside SSH's `known_hosts`.
206pub fn tls_pins_path(known_hosts: &Path) -> PathBuf {
207    known_hosts.with_file_name("tls_known_hosts")
208}
209
210/// RLogin's opening record (RFC 1282): client user name, server user name
211/// and terminal type/speed, each ending in a NUL, after a NUL. As SyncTERM
212/// does, BBSes get the password as the client user name and the user name
213/// as the server user name, so they can log the caller straight in.
214pub fn rlogin_handshake(entry: &Entry) -> Vec<u8> {
215    let term = if entry.term_type.is_empty() { "ansi-bbs".to_string() } else { entry.term_type.to_ascii_lowercase() };
216    let mut out = vec![0];
217    for field in [entry.password.as_str(), entry.username.as_str(), &format!("{term}/115200")] {
218        out.extend_from_slice(field.as_bytes());
219        out.push(0);
220    }
221    out
222}
223
224/// A TCP connection, sending `handshake` first (RLogin), in which case the
225/// server's one-byte NUL reply is not passed on.
226fn spawn_tcp<F>(host: String, port: u16, handshake: Option<Vec<u8>>, sink: F)
227where
228    F: Fn(NetEvent) + Send + 'static,
229{
230    thread::spawn(move || {
231        let mut stream = match connect_tcp(&host, port) {
232            Ok(s) => s,
233            Err(e) => return sink(NetEvent::Closed(Some(format!("{e:#}")))),
234        };
235        let mut expect_ack = false;
236        if let Some(h) = &handshake {
237            if let Err(e) = stream.write_all(h) {
238                return sink(NetEvent::Closed(Some(format!("sending the RLogin login: {e}"))));
239            }
240            expect_ack = true;
241        }
242        match stream.try_clone() {
243            Ok(w) => sink(NetEvent::Connected(Writer(WriterKind::Tcp(w)))),
244            Err(e) => return sink(NetEvent::Closed(Some(e.to_string()))),
245        }
246        let mut buf = vec![0u8; 16 * 1024];
247        loop {
248            match stream.read(&mut buf) {
249                Ok(0) => return sink(NetEvent::Closed(None)),
250                Ok(n) => {
251                    let mut data = &buf[..n];
252                    if expect_ack {
253                        expect_ack = false;
254                        data = data.strip_prefix(&[0]).unwrap_or(data);
255                    }
256                    if !data.is_empty() {
257                        sink(NetEvent::Data(data.to_vec()));
258                    }
259                }
260                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
261                Err(e) => return sink(NetEvent::Closed(Some(e.to_string()))),
262            }
263        }
264    });
265}
266
267fn connect_tcp(host: &str, port: u16) -> Result<TcpStream> {
268    let addrs: Vec<_> = (host, port).to_socket_addrs().with_context(|| format!("resolving {host}"))?.collect();
269    let mut last_err = None;
270    for addr in addrs {
271        match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
272            Ok(s) => {
273                let _ = s.set_nodelay(true);
274                return Ok(s);
275            }
276            Err(e) => last_err = Some(e),
277        }
278    }
279    match last_err {
280        Some(e) => Err(e).with_context(|| format!("connecting to {host}:{port}")),
281        None => bail!("{host} has no addresses"),
282    }
283}
284
285struct SshParams {
286    host: String,
287    port: u16,
288    user: String,
289    password: String,
290    key_file: Option<PathBuf>,
291    agent: bool,
292    key_dir: Option<PathBuf>,
293    term: String,
294    cols: u16,
295    rows: u16,
296    known_hosts: PathBuf,
297}
298
299/// A thread-safe wrapper around the caller's sink, so it can be shared with
300/// the SSH handler.
301#[derive(Clone)]
302pub(crate) struct Emitter(Arc<dyn Fn(NetEvent) + Send + Sync>);
303
304impl Emitter {
305    /// Wrap a sink that is only `Send`, for sharing between tasks.
306    pub(crate) fn new<F: Fn(NetEvent) + Send + 'static>(sink: F) -> Emitter {
307        let sink = Mutex::new(sink);
308        Emitter(Arc::new(move |ev| (sink.lock().unwrap_or_else(|e| e.into_inner()))(ev)))
309    }
310
311    pub(crate) fn emit(&self, ev: NetEvent) {
312        (self.0)(ev)
313    }
314}
315
316fn spawn_ssh<F>(params: SshParams, sink: F)
317where
318    F: Fn(NetEvent) + Send + 'static,
319{
320    thread::spawn(move || {
321        let emitter = Emitter::new(sink);
322        let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
323            Ok(rt) => rt,
324            Err(e) => return emitter.emit(NetEvent::Closed(Some(format!("starting SSH runtime: {e}")))),
325        };
326        let result = rt.block_on(run_ssh(params, emitter.clone()));
327        emitter.emit(NetEvent::Closed(result.err().map(|e| format!("{e:#}"))));
328    });
329}
330
331struct HostKeyCheck {
332    host: String,
333    port: u16,
334    known_hosts: PathBuf,
335    emitter: Emitter,
336    /// Why the key was rejected, reported instead of russh's generic error.
337    problem: Arc<Mutex<Option<String>>>,
338}
339
340impl client::Handler for HostKeyCheck {
341    type Error = russh::Error;
342
343    async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
344        let key = match key {
345            PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(),
346            PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()),
347        };
348        match verify_host_key(&self.host, self.port, &key, &self.known_hosts) {
349            Ok(notice) => {
350                if let Some(n) = notice {
351                    self.emitter.emit(NetEvent::Notice(n));
352                }
353                Ok(true)
354            }
355            Err(msg) => {
356                *self.problem.lock().unwrap() = Some(msg);
357                Ok(false)
358            }
359        }
360    }
361}
362
363/// Trust-on-first-use host key check. Returns a notice when a new key was
364/// recorded, or an error message when the key does not match the pinned one.
365pub fn verify_host_key(host: &str, port: u16, key: &PublicKey, known_hosts: &Path) -> Result<Option<String>, String> {
366    let fingerprint = key.fingerprint(HashAlg::Sha256);
367    match check_known_hosts_path(host, port, key, known_hosts) {
368        Ok(true) => Ok(None),
369        Ok(false) => {
370            learn_known_hosts_path(host, port, key, known_hosts)
371                .map_err(|e| format!("saving host key to {}: {e}", known_hosts.display()))?;
372            Ok(Some(format!("Trusted new SSH host key for {host}: {fingerprint}")))
373        }
374        Err(russh::keys::Error::KeyChanged { line }) => Err(format!(
375            "The SSH host key for {host} has CHANGED (now {fingerprint}). Someone may be intercepting the \
376             connection. If the BBS really changed its key, delete line {line} of {}.",
377            known_hosts.display()
378        )),
379        Err(e) => Err(format!("reading {}: {e}", known_hosts.display())),
380    }
381}
382
383async fn run_ssh(p: SshParams, emitter: Emitter) -> Result<()> {
384    if p.user.is_empty() {
385        bail!("SSH needs a user name: edit the entry and fill in \"User name\"");
386    }
387    let config = Arc::new(client::Config { keepalive_interval: Some(Duration::from_secs(30)), ..Default::default() });
388    let problem = Arc::new(Mutex::new(None));
389    let handler = HostKeyCheck {
390        host: p.host.clone(),
391        port: p.port,
392        known_hosts: p.known_hosts.clone(),
393        emitter: emitter.clone(),
394        problem: problem.clone(),
395    };
396    let connect = client::connect(config, (p.host.as_str(), p.port), handler);
397    let mut session = match tokio::time::timeout(CONNECT_TIMEOUT, connect).await {
398        Err(_) => bail!("connecting to {}:{}: timed out", p.host, p.port),
399        Ok(Err(e)) => {
400            if let Some(msg) = problem.lock().unwrap().take() {
401                bail!(msg);
402            }
403            return Err(anyhow!(e)).with_context(|| format!("connecting to {}:{}", p.host, p.port));
404        }
405        Ok(Ok(s)) => s,
406    };
407
408    Login { session: &mut session, p: &p, emitter: &emitter }.run().await?;
409
410    let channel = session.channel_open_session().await.context("opening SSH channel")?;
411    channel
412        .request_pty(false, &p.term, p.cols.into(), p.rows.into(), 0, 0, &[])
413        .await
414        .context("requesting a terminal")?;
415    channel.request_shell(false).await.context("starting the shell")?;
416
417    let (tx, mut rx) = mpsc::unbounded_channel();
418    emitter.emit(NetEvent::Connected(Writer(WriterKind::Channel(tx))));
419
420    let (mut read, write) = channel.split();
421    loop {
422        tokio::select! {
423            msg = read.wait() => match msg {
424                Some(ChannelMsg::Data { data }) => emitter.emit(NetEvent::Data(data.to_vec())),
425                Some(ChannelMsg::ExtendedData { data, .. }) => emitter.emit(NetEvent::Data(data.to_vec())),
426                Some(ChannelMsg::Eof | ChannelMsg::Close) | None => break,
427                Some(_) => {}
428            },
429            cmd = rx.recv() => match cmd {
430                Some(Cmd::Data(d)) => write.data(&d[..]).await.context("sending data")?,
431                Some(Cmd::Close) | None => break,
432            },
433        }
434    }
435    let _ = session.disconnect(Disconnect::ByApplication, "", "en").await;
436    Ok(())
437}
438
439/// Expand a leading `~/` to the home directory.
440fn expand_home(p: &str) -> PathBuf {
441    match (p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")), std::env::home_dir()) {
442        (Some(rest), Some(home)) => home.join(rest),
443        _ => PathBuf::from(p),
444    }
445}
446
447/// How a login attempt went.
448enum Attempt {
449    Success,
450    /// Rejected; the server lists the methods still worth trying.
451    Failed(Vec<MethodKind>),
452}
453
454impl From<AuthResult> for Attempt {
455    fn from(r: AuthResult) -> Attempt {
456        match r {
457            AuthResult::Success => Attempt::Success,
458            AuthResult::Failure { remaining_methods, .. } => Attempt::Failed(remaining_methods.to_vec()),
459        }
460    }
461}
462
463/// The SSH login sequence.
464struct Login<'a, H: client::Handler> {
465    session: &'a mut client::Handle<H>,
466    p: &'a SshParams,
467    emitter: &'a Emitter,
468}
469
470impl<H: client::Handler> Login<'_, H> {
471    /// Try "none" (common for BBSes that run their own login), then public
472    /// keys, then keyboard-interactive or password, prompting for whatever
473    /// is not stored.
474    async fn run(&mut self) -> Result<()> {
475        let user = self.p.user.clone();
476        let mut methods = match self.session.authenticate_none(&user).await?.into() {
477            Attempt::Success => return Ok(()),
478            Attempt::Failed(m) => m,
479        };
480        if methods.contains(&MethodKind::PublicKey) {
481            match self.public_keys().await? {
482                Some(Attempt::Success) => return Ok(()),
483                Some(Attempt::Failed(m)) => methods = m,
484                None => {}
485            }
486        }
487        // Like OpenSSH, keyboard-interactive first: servers that offer it
488        // (PAM, one-time codes) often refuse plain password logins. If it
489        // already asked the user, the password method would only ask again.
490        let mut asked = false;
491        if methods.contains(&MethodKind::KeyboardInteractive) {
492            let (attempt, a) = self.keyboard_interactive().await?;
493            asked = a;
494            match attempt {
495                Attempt::Success => return Ok(()),
496                Attempt::Failed(m) => methods = m,
497            }
498        }
499        if !asked && methods.contains(&MethodKind::Password) {
500            match self.password().await? {
501                Attempt::Success => return Ok(()),
502                Attempt::Failed(m) => methods = m,
503            }
504        }
505        let offered: Vec<&str> = methods.iter().map(<&str>::from).collect();
506        log::debug!("SSH login refused; server still offers {offered:?}");
507        bail!("SSH login as \"{user}\" was refused: check the user name and password")
508    }
509
510    async fn ask(&self, kind: PromptKind, instructions: String, prompt: String, echo: bool) -> Option<String> {
511        let (reply, answer) = oneshot::channel();
512        let title = format!("SSH login to {}", self.p.host);
513        self.emitter.emit(NetEvent::Prompt(AuthPrompt { kind, title, instructions, prompt, echo, reply }));
514        answer.await.ok().flatten()
515    }
516
517    async fn ask_password(&self, retry: bool) -> Result<String> {
518        let why = if retry { "That password was not accepted." } else { "" };
519        let prompt = format!("Password for {}:", self.p.user);
520        self.ask(PromptKind::Password, why.into(), prompt, false).await.context(LOGIN_CANCELLED)
521    }
522
523    /// The stored password once, then up to [`MAX_PROMPTED_ATTEMPTS`]
524    /// typed ones.
525    async fn password(&mut self) -> Result<Attempt> {
526        let mut stored = (!self.p.password.is_empty()).then(|| self.p.password.clone());
527        let (mut retry, mut asked) = (false, 0);
528        let mut last = Attempt::Failed(vec![MethodKind::Password]);
529        while asked < MAX_PROMPTED_ATTEMPTS {
530            let pw = match stored.take() {
531                Some(pw) => pw,
532                None => {
533                    asked += 1;
534                    self.ask_password(retry).await?
535                }
536            };
537            last = self.session.authenticate_password(&self.p.user, pw).await?.into();
538            match &last {
539                Attempt::Failed(m) if m.contains(&MethodKind::Password) => retry = true,
540                _ => break,
541            }
542        }
543        Ok(last)
544    }
545
546    /// Answer the server's questions. A lone hidden question is taken to be
547    /// the password, so a stored one answers it the first time. Also returns
548    /// whether the user was asked anything.
549    async fn keyboard_interactive(&mut self) -> Result<(Attempt, bool)> {
550        let user = self.p.user.clone();
551        let mut stored = (!self.p.password.is_empty()).then(|| self.p.password.clone());
552        let (mut retry, mut asked) = (false, false);
553        let mut last = Attempt::Failed(vec![MethodKind::KeyboardInteractive]);
554        // A stored password gets one try ahead of the typed ones.
555        let attempts = MAX_PROMPTED_ATTEMPTS + usize::from(stored.is_some());
556        'attempts: for _ in 0..attempts {
557            let mut resp = self.session.authenticate_keyboard_interactive_start(&user, None).await?;
558            for _ in 0..MAX_KBD_INTERACTIVE_ROUNDS {
559                let (name, instructions, prompts) = match resp {
560                    KeyboardInteractiveAuthResponse::Success => return Ok((Attempt::Success, asked)),
561                    KeyboardInteractiveAuthResponse::Failure { remaining_methods, .. } => {
562                        last = Attempt::Failed(remaining_methods.to_vec());
563                        // Retry only after a wrong answer: one the user typed,
564                        // or the stored password (the user is asked next time).
565                        let used_stored = stored.is_none() && !self.p.password.is_empty();
566                        if !remaining_methods.contains(&MethodKind::KeyboardInteractive) || !asked && !used_stored {
567                            break 'attempts;
568                        }
569                        retry = true;
570                        continue 'attempts;
571                    }
572                    KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts } => {
573                        (name, instructions, prompts)
574                    }
575                };
576                let mut answers = Vec::with_capacity(prompts.len());
577                if let ([only], Some(pw)) = (prompts.as_slice(), stored.as_ref())
578                    && !only.echo
579                {
580                    answers.push(pw.clone());
581                    stored = None;
582                } else {
583                    let mut info = [name.trim(), instructions.trim()]
584                        .into_iter()
585                        .filter(|s| !s.is_empty())
586                        .collect::<Vec<_>>()
587                        .join("\n");
588                    if retry && info.is_empty() {
589                        info = "That answer was not accepted.".into();
590                    }
591                    for q in &prompts {
592                        let kind = if q.echo { PromptKind::Interactive } else { PromptKind::Password };
593                        let text = if q.prompt.trim().is_empty() { "Password:" } else { q.prompt.trim() };
594                        asked = true;
595                        let answer = self.ask(kind, info.clone(), text.into(), q.echo).await;
596                        answers.push(answer.context(LOGIN_CANCELLED)?);
597                    }
598                }
599                resp = self.session.authenticate_keyboard_interactive_respond(answers).await?;
600            }
601            break;
602        }
603        Ok((last, asked))
604    }
605
606    /// The entry's key file, then the agent's keys, then the default key
607    /// files. Returns None if no key was offered at all.
608    async fn public_keys(&mut self) -> Result<Option<Attempt>> {
609        let rsa_hash = self.session.best_supported_rsa_hash().await.ok().flatten().flatten();
610        let mut offered: Vec<PublicKey> = Vec::new();
611        let mut last = None;
612
613        if let Some(path) = self.p.key_file.clone()
614            && let Some(key) = self.load_key(&path, true).await?
615        {
616            offered.push(key.public_key().clone());
617            let r = self.offer_key(key, rsa_hash).await?;
618            if matches!(r, Attempt::Success) {
619                return Ok(Some(r));
620            }
621            last = Some(r);
622        }
623
624        if self.p.agent
625            && let Some(mut agent) = connect_agent().await
626        {
627            let identities = agent.request_identities().await.unwrap_or_else(|e| {
628                log::debug!("listing SSH agent keys: {e}");
629                Vec::new()
630            });
631            for id in identities {
632                let AgentIdentity::PublicKey { key, .. } = id else { continue };
633                if offered.contains(&key) {
634                    continue;
635                }
636                offered.push(key.clone());
637                let hash = if key.algorithm().is_rsa() { rsa_hash } else { None };
638                match self.session.authenticate_publickey_with(&self.p.user, key, hash, &mut agent).await {
639                    Ok(r) => match Attempt::from(r) {
640                        Attempt::Success => return Ok(Some(Attempt::Success)),
641                        failed => last = Some(failed),
642                    },
643                    Err(e) => log::debug!("SSH agent signing failed: {e}"),
644                }
645            }
646        }
647
648        if let Some(dir) = self.p.key_dir.clone() {
649            for name in DEFAULT_KEY_FILES {
650                let path = dir.join(name);
651                if Some(&path) == self.p.key_file.as_ref() || !path.exists() {
652                    continue;
653                }
654                // Encrypted default keys are skipped rather than asking for
655                // their passphrase on every BBS that offers public keys;
656                // load them into the agent, or name one in the entry.
657                let Some(key) = self.load_key(&path, false).await? else { continue };
658                if offered.contains(key.public_key()) {
659                    continue;
660                }
661                offered.push(key.public_key().clone());
662                match self.offer_key(key, rsa_hash).await? {
663                    Attempt::Success => return Ok(Some(Attempt::Success)),
664                    failed => last = Some(failed),
665                }
666            }
667        }
668        Ok(last)
669    }
670
671    async fn offer_key(&mut self, key: PrivateKey, rsa_hash: Option<HashAlg>) -> Result<Attempt> {
672        let key = PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash);
673        Ok(self.session.authenticate_publickey(&self.p.user, key).await?.into())
674    }
675
676    /// Load a private key. For the entry's own key (`ask`), an encrypted key
677    /// prompts for its passphrase and problems are reported; otherwise they
678    /// only skip the key.
679    async fn load_key(&self, path: &Path, ask: bool) -> Result<Option<PrivateKey>> {
680        let shown = path.display().to_string();
681        let mut why = String::new();
682        let mut passphrase: Option<String> = None;
683        for _ in 0..=MAX_PROMPTED_ATTEMPTS {
684            match russh::keys::load_secret_key(path, passphrase.as_deref()) {
685                Ok(key) => return Ok(Some(key)),
686                Err(e) if !ask => {
687                    log::debug!("skipping SSH key {shown}: {e}");
688                    return Ok(None);
689                }
690                Err(russh::keys::Error::KeyIsEncrypted) => {}
691                // A wrong passphrase shows up as a decryption error.
692                Err(russh::keys::Error::SshKey(_)) if passphrase.is_some() => {
693                    why = "That passphrase was not accepted.".into();
694                }
695                Err(e) => {
696                    self.emitter.emit(NetEvent::Notice(format!("Cannot use SSH key {shown}: {e}")));
697                    return Ok(None);
698                }
699            }
700            let prompt = format!("Passphrase for {shown}:");
701            match self.ask(PromptKind::Passphrase, why.clone(), prompt, false).await {
702                Some(p) => passphrase = Some(p),
703                // Skip the key; other methods may still work.
704                None => return Ok(None),
705            }
706        }
707        Ok(None)
708    }
709}
710
711/// Connect to the user's SSH agent, if one is running.
712async fn connect_agent() -> Option<AgentClient<Box<dyn AgentStream + Send + Unpin>>> {
713    #[cfg(unix)]
714    {
715        match AgentClient::connect_env().await {
716            Ok(a) => Some(a.dynamic()),
717            Err(e) => {
718                log::debug!("no SSH agent: {e}");
719                None
720            }
721        }
722    }
723    #[cfg(windows)]
724    {
725        // The Windows OpenSSH agent service, then PuTTY's Pageant.
726        if let Ok(a) = AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await {
727            return Some(a.dynamic());
728        }
729        match AgentClient::connect_pageant().await {
730            Ok(a) => Some(a.dynamic()),
731            Err(e) => {
732                log::debug!("no SSH agent: {e}");
733                None
734            }
735        }
736    }
737    #[cfg(not(any(unix, windows)))]
738    None
739}