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