Skip to main content

anvil_ssh/
session.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3// Updated 2026-04-12: added verified_fingerprint tracking for SFRS JSON output
4//! SSH session management (FR-1 through FR-5, FR-9 through FR-17).
5//!
6//! [`AnvilSession`] wraps a russh [`client::Handle`] and exposes the
7//! operations Gitway needs: connect, authenticate, exec, and close.
8//!
9//! Host-key verification is performed inside [`GitwayHandler::check_server_key`]
10//! using the fingerprints collected by [`crate::hostkey`].
11
12use std::borrow::Cow;
13use std::fmt;
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16
17use russh::client;
18use russh::keys::{HashAlg, PrivateKeyWithHashAlg};
19use russh::{cipher, kex, Disconnect, Preferred};
20
21use std::path::PathBuf;
22
23use crate::config::AnvilConfig;
24use crate::error::{AnvilError, AnvilErrorKind};
25use crate::hostkey;
26use crate::relay;
27use crate::ssh_config::StrictHostKeyChecking;
28
29// ── Handler ───────────────────────────────────────────────────────────────────
30
31/// russh client event handler.
32///
33/// Validates the server host key (FR-6, FR-7, FR-8) and captures any
34/// authentication banner the server sends before confirming the session.
35struct GitwayHandler {
36    /// Expected SHA-256 fingerprints for the target host.  May be empty
37    /// in [`StrictHostKeyChecking::AcceptNew`] mode for an unknown host
38    /// — the handler will record the first fingerprint it sees in that
39    /// case.
40    fingerprints: Vec<String>,
41    /// Host-key verification policy (FR-8).
42    policy: StrictHostKeyChecking,
43    /// Hostname being connected to — needed by the
44    /// [`StrictHostKeyChecking::AcceptNew`] write path so the new
45    /// fingerprint line can be labelled with the right host.
46    host: String,
47    /// Path to the user-configured `known_hosts` file, if any.  Required
48    /// for [`StrictHostKeyChecking::AcceptNew`] writes; if `None`, the
49    /// handler downgrades to [`StrictHostKeyChecking::Yes`] semantics
50    /// with a warning.
51    custom_known_hosts: Option<PathBuf>,
52    /// Buffer for the last authentication banner received from the server.
53    ///
54    /// GitHub sends "Hi <user>! You've successfully authenticated…" here.
55    auth_banner: Arc<Mutex<Option<String>>>,
56    /// The SHA-256 fingerprint of the server key that passed verification.
57    ///
58    /// Set during `check_server_key`; exposed via
59    /// [`AnvilSession::verified_fingerprint`] for structured JSON output.
60    verified_fingerprint: Arc<Mutex<Option<String>>>,
61}
62
63impl fmt::Debug for GitwayHandler {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.debug_struct("GitwayHandler")
66            .field("fingerprints", &self.fingerprints)
67            .field("policy", &self.policy)
68            .field("host", &self.host)
69            .field("custom_known_hosts", &self.custom_known_hosts)
70            .field("auth_banner", &self.auth_banner)
71            .field("verified_fingerprint", &self.verified_fingerprint)
72            .finish()
73    }
74}
75
76impl client::Handler for GitwayHandler {
77    type Error = AnvilError;
78
79    async fn check_server_key(
80        &mut self,
81        server_public_key: &russh::keys::ssh_key::PublicKey,
82    ) -> Result<bool, Self::Error> {
83        let fp = server_public_key.fingerprint(HashAlg::Sha256).to_string();
84        log::debug!("session: checking server host key {fp}");
85
86        // StrictHostKeyChecking=No: accept any key.  Equivalent to the
87        // 0.2.x `--insecure-skip-host-check` path.
88        if matches!(self.policy, StrictHostKeyChecking::No) {
89            log::warn!("host-key verification skipped (StrictHostKeyChecking=No)");
90            if let Ok(mut guard) = self.verified_fingerprint.lock() {
91                *guard = Some(fp);
92            }
93            return Ok(true);
94        }
95
96        // Match against the pinned/known set first.  This path is
97        // identical for `Yes` and `AcceptNew`: a verified existing
98        // fingerprint always passes.
99        if self.fingerprints.iter().any(|f| f == &fp) {
100            log::debug!("session: host key verified: {fp}");
101            if let Ok(mut guard) = self.verified_fingerprint.lock() {
102                *guard = Some(fp);
103            }
104            return Ok(true);
105        }
106
107        // No match.  In `AcceptNew` mode with a fully-unknown host (no
108        // existing fingerprints at all) AND a writable
109        // `custom_known_hosts` path, record the new fingerprint and
110        // accept.  Any other case is a hard mismatch.
111        if matches!(self.policy, StrictHostKeyChecking::AcceptNew) && self.fingerprints.is_empty() {
112            if let Some(path) = &self.custom_known_hosts {
113                hostkey::append_known_host(path, &self.host, &fp)?;
114                log::info!(
115                    "host-key first-use accepted: {} -> {} (recorded in {})",
116                    self.host,
117                    fp,
118                    path.display(),
119                );
120                if let Ok(mut guard) = self.verified_fingerprint.lock() {
121                    *guard = Some(fp);
122                }
123                return Ok(true);
124            }
125            log::warn!(
126                "StrictHostKeyChecking=accept-new requested but no \
127                 custom_known_hosts path is set; downgrading to Yes \
128                 semantics for {}",
129                self.host,
130            );
131        }
132
133        Err(AnvilError::host_key_mismatch(fp))
134    }
135
136    async fn auth_banner(
137        &mut self,
138        banner: &str,
139        _session: &mut client::Session,
140    ) -> Result<(), Self::Error> {
141        let trimmed = banner.trim().to_owned();
142        log::info!("server banner: {banner}");
143        if let Ok(mut guard) = self.auth_banner.lock() {
144            *guard = Some(trimmed);
145        }
146        Ok(())
147    }
148}
149
150// ── Session ───────────────────────────────────────────────────────────────────
151
152/// An active SSH session connected to a GitHub (or GHE) host.
153///
154/// # Typical Usage
155///
156/// ```no_run
157/// use anvil_ssh::{AnvilConfig, AnvilSession};
158///
159/// # async fn doc() -> Result<(), anvil_ssh::AnvilError> {
160/// let config = AnvilConfig::github();
161/// let mut session = AnvilSession::connect(&config).await?;
162/// // authenticate, exec, close…
163/// # Ok(())
164/// # }
165/// ```
166pub struct AnvilSession {
167    handle: client::Handle<GitwayHandler>,
168    /// Authentication banner received from the server, if any.
169    auth_banner: Arc<Mutex<Option<String>>>,
170    /// SHA-256 fingerprint of the server key that passed verification, if any.
171    verified_fingerprint: Arc<Mutex<Option<String>>>,
172}
173
174/// Manual Debug impl because `client::Handle<H>` does not implement `Debug`.
175impl fmt::Debug for AnvilSession {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        f.debug_struct("AnvilSession").finish_non_exhaustive()
178    }
179}
180
181impl AnvilSession {
182    // ── Construction ─────────────────────────────────────────────────────────
183
184    /// Establishes a TCP connection to the host in `config` and completes the
185    /// SSH handshake (including host-key verification).
186    ///
187    /// Does **not** authenticate; call [`authenticate`](Self::authenticate) or
188    /// [`authenticate_best`](Self::authenticate_best) after this.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error on network failure or if the server's host key does not
193    /// match any pinned fingerprint.
194    pub async fn connect(config: &AnvilConfig) -> Result<Self, AnvilError> {
195        let russh_cfg = Arc::new(build_russh_config(config.inactivity_timeout));
196        // For StrictHostKeyChecking=AcceptNew with a writable known_hosts
197        // path, an empty fingerprint set is acceptable — the handler will
198        // record the first fingerprint it sees.  Every other policy
199        // (Yes / No) treats the lookup error as fatal as before.
200        let fingerprints =
201            match hostkey::fingerprints_for_host(&config.host, &config.custom_known_hosts) {
202                Ok(fps) => fps,
203                Err(e) => {
204                    if matches!(
205                        config.strict_host_key_checking,
206                        StrictHostKeyChecking::AcceptNew
207                    ) && config.custom_known_hosts.is_some()
208                    {
209                        log::info!(
210                            "session: no fingerprints known for {}; \
211                         accept-new will record on first connection",
212                            config.host,
213                        );
214                        Vec::new()
215                    } else {
216                        return Err(e);
217                    }
218                }
219            };
220        let auth_banner = Arc::new(Mutex::new(None));
221        let verified_fingerprint = Arc::new(Mutex::new(None));
222
223        let handler = GitwayHandler {
224            fingerprints,
225            policy: config.strict_host_key_checking,
226            host: config.host.clone(),
227            custom_known_hosts: config.custom_known_hosts.clone(),
228            auth_banner: Arc::clone(&auth_banner),
229            verified_fingerprint: Arc::clone(&verified_fingerprint),
230        };
231
232        log::debug!("session: connecting to {}:{}", config.host, config.port);
233
234        let handle =
235            client::connect(russh_cfg, (config.host.as_str(), config.port), handler).await?;
236
237        log::debug!("session: SSH handshake complete with {}", config.host);
238
239        Ok(Self {
240            handle,
241            auth_banner,
242            verified_fingerprint,
243        })
244    }
245
246    // ── Authentication ────────────────────────────────────────────────────────
247
248    /// Authenticates with an explicit key.
249    ///
250    /// Use [`authenticate_best`] to let the library discover the key
251    /// automatically.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error on SSH protocol failures.  Returns
256    /// [`AnvilError::is_authentication_failed`] when the server accepts the
257    /// exchange but rejects the key.
258    pub async fn authenticate(
259        &mut self,
260        username: &str,
261        key: PrivateKeyWithHashAlg,
262    ) -> Result<(), AnvilError> {
263        log::debug!("session: authenticating as {username}");
264
265        let result = self.handle.authenticate_publickey(username, key).await?;
266
267        if result.success() {
268            log::debug!("session: authentication succeeded for {username}");
269            Ok(())
270        } else {
271            Err(AnvilError::authentication_failed())
272        }
273    }
274
275    /// Authenticates with a private key and an accompanying OpenSSH certificate
276    /// (FR-12).
277    ///
278    /// The certificate is presented to the server in place of the raw public
279    /// key.  This is typically used with organisation-issued certificates that
280    /// grant access without requiring the public key to be listed in
281    /// `authorized_keys`.
282    ///
283    /// # Errors
284    ///
285    /// Returns an error on SSH protocol failures or if the server rejects the
286    /// certificate.
287    pub async fn authenticate_with_cert(
288        &mut self,
289        username: &str,
290        key: russh::keys::PrivateKey,
291        cert: russh::keys::Certificate,
292    ) -> Result<(), AnvilError> {
293        log::debug!("session: authenticating as {username} with OpenSSH certificate");
294
295        let result = self
296            .handle
297            .authenticate_openssh_cert(username, Arc::new(key), cert)
298            .await?;
299
300        if result.success() {
301            log::debug!("session: certificate authentication succeeded for {username}");
302            Ok(())
303        } else {
304            Err(AnvilError::authentication_failed())
305        }
306    }
307
308    /// Discovers the best available key and authenticates using it.
309    ///
310    /// Priority order (FR-9):
311    /// 1. Explicit `--identity` path from config.
312    /// 2. Default `.ssh` paths (`id_ed25519` → `id_ecdsa` → `id_rsa`).
313    /// 3. SSH agent via `$SSH_AUTH_SOCK` (Unix only).
314    ///
315    /// If a certificate path is configured in `config.cert_file`, certificate
316    /// authentication (FR-12) is used instead of raw public-key authentication
317    /// for file-based keys.
318    ///
319    /// When the chosen key requires a passphrase this method returns an error
320    /// whose [`is_key_encrypted`](AnvilError::is_key_encrypted) predicate is
321    /// `true`; the caller (CLI layer) should then prompt and call
322    /// [`authenticate_with_passphrase`](Self::authenticate_with_passphrase).
323    ///
324    /// # Errors
325    ///
326    /// Returns [`AnvilError::is_no_key_found`] when no key is available via
327    /// any discovery method.
328    pub async fn authenticate_best(&mut self, config: &AnvilConfig) -> Result<(), AnvilError> {
329        use crate::auth::{find_identity, wrap_key, IdentityResolution};
330
331        let resolution = find_identity(config)?;
332
333        match resolution {
334            IdentityResolution::Found { key, .. } => {
335                return self.auth_key_or_cert(config, key).await;
336            }
337            IdentityResolution::Encrypted { path } => {
338                log::debug!(
339                    "session: key at {} is passphrase-protected; trying SSH agent first",
340                    path.display()
341                );
342                // Try the agent before asking for a passphrase.  The key may
343                // already be loaded via `ssh-add`, and a passphrase prompt is
344                // impossible when gitway is spawned by Git without a terminal.
345                #[cfg(unix)]
346                {
347                    use crate::auth::connect_agent;
348                    if let Some(conn) = connect_agent().await? {
349                        match self.authenticate_with_agent(&config.username, conn).await {
350                            Ok(()) => return Ok(()),
351                            Err(e) if e.is_authentication_failed() => {
352                                log::debug!(
353                                    "session: agent could not authenticate; \
354                                     will request passphrase for {}",
355                                    path.display()
356                                );
357                            }
358                            Err(e) => return Err(e),
359                        }
360                    }
361                }
362                return Err(AnvilError::new(AnvilErrorKind::Keys(
363                    russh::keys::Error::KeyIsEncrypted,
364                )));
365            }
366            IdentityResolution::NotFound => {
367                // Fall through to agent (below).
368            }
369        }
370
371        // Priority 3: SSH agent — reached only when no file-based key exists (FR-9).
372        #[cfg(unix)]
373        {
374            use crate::auth::connect_agent;
375            if let Some(conn) = connect_agent().await? {
376                return self.authenticate_with_agent(&config.username, conn).await;
377            }
378        }
379
380        // For RSA keys, ask the server which hash algorithm it prefers (FR-11).
381        // This branch is only reached when we must still try a key via wrap_key
382        // after exhausting the above — currently unused, but kept for clarity.
383        let _ = wrap_key; // suppress unused-import warning on non-Unix builds
384        Err(AnvilError::no_key_found())
385    }
386
387    /// Loads an encrypted key with `passphrase` and authenticates.
388    ///
389    /// Call this after [`authenticate_best`] returns an encrypted-key error
390    /// and the CLI has collected the passphrase from the terminal.
391    ///
392    /// If `config.cert_file` is set, certificate authentication is used
393    /// (FR-12).
394    ///
395    /// # Errors
396    ///
397    /// Returns an error if the passphrase is wrong or authentication fails.
398    pub async fn authenticate_with_passphrase(
399        &mut self,
400        config: &AnvilConfig,
401        path: &std::path::Path,
402        passphrase: &str,
403    ) -> Result<(), AnvilError> {
404        use crate::auth::load_encrypted_key;
405
406        let key = load_encrypted_key(path, passphrase)?;
407        self.auth_key_or_cert(config, key).await
408    }
409
410    /// Tries each identity held in `conn` until one succeeds or all are
411    /// exhausted.
412    ///
413    /// On Unix this is called automatically by [`authenticate_best`] when no
414    /// file-based key is found.  For plain public-key identities the signing
415    /// challenge is forwarded to the agent; for certificate identities the
416    /// full certificate is presented alongside the agent-signed challenge.
417    ///
418    /// # Errors
419    ///
420    /// Returns [`AnvilError::is_authentication_failed`] if all identities are
421    /// rejected, or [`AnvilError::is_no_key_found`] if the agent was empty.
422    #[cfg(unix)]
423    pub async fn authenticate_with_agent(
424        &mut self,
425        username: &str,
426        mut conn: crate::auth::AgentConnection,
427    ) -> Result<(), AnvilError> {
428        use russh::keys::agent::AgentIdentity;
429
430        for identity in conn.identities.clone() {
431            let result = match &identity {
432                AgentIdentity::PublicKey { key, .. } => {
433                    let hash_alg = if key.algorithm().is_rsa() {
434                        self.handle
435                            .best_supported_rsa_hash()
436                            .await?
437                            .flatten()
438                            // Fall back to SHA-256 when the server offers no guidance (FR-11).
439                            .or(Some(HashAlg::Sha256))
440                    } else {
441                        None
442                    };
443                    self.handle
444                        .authenticate_publickey_with(
445                            username,
446                            key.clone(),
447                            hash_alg,
448                            &mut conn.client,
449                        )
450                        .await
451                        .map_err(AnvilError::from)
452                }
453                AgentIdentity::Certificate { certificate, .. } => self
454                    .handle
455                    .authenticate_certificate_with(
456                        username,
457                        certificate.clone(),
458                        None,
459                        &mut conn.client,
460                    )
461                    .await
462                    .map_err(AnvilError::from),
463            };
464
465            match result? {
466                r if r.success() => {
467                    log::debug!("session: agent authentication succeeded");
468                    return Ok(());
469                }
470                _ => {
471                    log::debug!("session: agent identity rejected; trying next");
472                }
473            }
474        }
475
476        Err(AnvilError::no_key_found())
477    }
478
479    // ── Exec / relay ──────────────────────────────────────────────────────────
480
481    /// Opens a session channel, executes `command`, and relays stdio
482    /// bidirectionally until the remote process exits.
483    ///
484    /// Returns the remote exit code (FR-16).  Exit-via-signal returns
485    /// `128 + signal_number` (FR-17).
486    ///
487    /// # Errors
488    ///
489    /// Returns an error on channel open failure or SSH protocol errors.
490    pub async fn exec(&mut self, command: &str) -> Result<u32, AnvilError> {
491        log::debug!("session: opening exec channel for '{command}'");
492
493        let channel = self.handle.channel_open_session().await?;
494        channel.exec(true, command).await?;
495
496        let exit_code = relay::relay_channel(channel).await?;
497
498        log::debug!("session: command '{command}' exited with code {exit_code}");
499
500        Ok(exit_code)
501    }
502
503    // ── Lifecycle ─────────────────────────────────────────────────────────────
504
505    /// Sends a graceful `SSH_MSG_DISCONNECT` and closes the connection.
506    ///
507    /// # Errors
508    ///
509    /// Returns an error if the disconnect message cannot be sent.
510    pub async fn close(self) -> Result<(), AnvilError> {
511        self.handle
512            .disconnect(Disconnect::ByApplication, "", "English")
513            .await?;
514        Ok(())
515    }
516
517    // ── Accessors ─────────────────────────────────────────────────────────────
518
519    /// Returns the authentication banner last received from the server (if any).
520    ///
521    /// For GitHub.com this contains the "Hi <user>!" welcome message.
522    ///
523    /// # Panics
524    ///
525    /// Panics if the internal mutex is poisoned, which can only occur if another
526    /// thread panicked while holding the lock — a programming error.
527    #[must_use]
528    pub fn auth_banner(&self) -> Option<String> {
529        self.auth_banner
530            .lock()
531            .expect("auth_banner lock is not poisoned")
532            .clone()
533    }
534
535    /// Returns the SHA-256 fingerprint of the server key that was verified.
536    ///
537    /// Available after a successful [`connect`](Self::connect).  Returns `None`
538    /// when host-key verification was skipped (`--insecure-skip-host-check`).
539    ///
540    /// # Panics
541    ///
542    /// Panics if the internal mutex is poisoned — a programming error.
543    #[must_use]
544    pub fn verified_fingerprint(&self) -> Option<String> {
545        self.verified_fingerprint
546            .lock()
547            .expect("verified_fingerprint lock is not poisoned")
548            .clone()
549    }
550
551    // ── Internal helpers ──────────────────────────────────────────────────────
552
553    /// Authenticates with `key`, using certificate auth if `config.cert_file`
554    /// is set (FR-12), otherwise plain public-key auth (FR-11).
555    async fn auth_key_or_cert(
556        &mut self,
557        config: &AnvilConfig,
558        key: russh::keys::PrivateKey,
559    ) -> Result<(), AnvilError> {
560        use crate::auth::{load_cert, wrap_key};
561
562        if let Some(ref cert_path) = config.cert_file {
563            let cert = load_cert(cert_path)?;
564            return self
565                .authenticate_with_cert(&config.username, key, cert)
566                .await;
567        }
568
569        // For RSA keys, ask the server which hash algorithm it prefers (FR-11).
570        let rsa_hash = if key.algorithm().is_rsa() {
571            self.handle
572                .best_supported_rsa_hash()
573                .await?
574                .flatten()
575                .or(Some(HashAlg::Sha256))
576        } else {
577            None
578        };
579
580        let wrapped = wrap_key(key, rsa_hash);
581        self.authenticate(&config.username, wrapped).await
582    }
583}
584
585// ── russh config builder ──────────────────────────────────────────────────────
586
587/// Constructs a russh [`client::Config`] with Gitway's preferred algorithms.
588///
589/// Algorithm preferences (FR-2, FR-3, FR-4):
590/// - Key exchange: `curve25519-sha256` (RFC 8731) with
591///   `curve25519-sha256@libssh.org` as fallback.
592/// - Cipher: `chacha20-poly1305@openssh.com`.
593/// - `ext-info-c` advertises server-sig-algs extension support.
594fn build_russh_config(inactivity_timeout: Duration) -> client::Config {
595    client::Config {
596        // 60 s matches GitHub's server-side idle threshold.
597        // Lowering below ~10 s risks spurious timeouts on high-latency links.
598        inactivity_timeout: Some(inactivity_timeout),
599        preferred: Preferred {
600            kex: Cow::Owned(vec![
601                kex::CURVE25519,                  // curve25519-sha256 (RFC 8731)
602                kex::CURVE25519_PRE_RFC_8731,     // curve25519-sha256@libssh.org
603                kex::EXTENSION_SUPPORT_AS_CLIENT, // ext-info-c (FR-4)
604            ]),
605            cipher: Cow::Owned(vec![
606                cipher::CHACHA20_POLY1305, // chacha20-poly1305@openssh.com (FR-3)
607            ]),
608            ..Default::default()
609        },
610        ..Default::default()
611    }
612}
613
614// ── Tests ─────────────────────────────────────────────────────────────────────
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    // ── NFR-6: legacy algorithm exclusion ────────────────────────────────────
621
622    /// 3DES-CBC must never appear in the negotiated cipher list (NFR-6).
623    ///
624    /// Our explicit cipher override contains only chacha20-poly1305, so 3DES
625    /// cannot be selected even if the server offers it.
626    #[test]
627    fn config_cipher_excludes_3des() {
628        let config = build_russh_config(Duration::from_secs(60));
629        let found = config
630            .preferred
631            .cipher
632            .iter()
633            .any(|c| c.as_ref() == "3des-cbc");
634        assert!(
635            !found,
636            "3DES-CBC must not appear in the cipher list (NFR-6)"
637        );
638    }
639
640    /// DSA must never appear in the key-algorithm list (NFR-6).
641    ///
642    /// russh's `Preferred::DEFAULT` already omits DSA; this test locks that
643    /// invariant so a russh upgrade cannot silently re-introduce it.
644    #[test]
645    fn config_key_algorithms_exclude_dsa() {
646        use russh::keys::Algorithm;
647
648        let config = build_russh_config(Duration::from_secs(60));
649        assert!(
650            !config.preferred.key.contains(&Algorithm::Dsa),
651            "DSA must not appear in the key-algorithm list (NFR-6)"
652        );
653    }
654
655    // ── FR-2 / FR-3 positive assertions ─────────────────────────────────────
656
657    /// curve25519-sha256 must be in the kex list (FR-2).
658    #[test]
659    fn config_kex_includes_curve25519() {
660        let config = build_russh_config(Duration::from_secs(60));
661        let found = config
662            .preferred
663            .kex
664            .iter()
665            .any(|k| k.as_ref() == "curve25519-sha256");
666        assert!(found, "curve25519-sha256 must be in the kex list (FR-2)");
667    }
668
669    /// chacha20-poly1305@openssh.com must be in the cipher list (FR-3).
670    #[test]
671    fn config_cipher_includes_chacha20_poly1305() {
672        let config = build_russh_config(Duration::from_secs(60));
673        let found = config
674            .preferred
675            .cipher
676            .iter()
677            .any(|c| c.as_ref() == "chacha20-poly1305@openssh.com");
678        assert!(
679            found,
680            "chacha20-poly1305@openssh.com must be in the cipher list (FR-3)"
681        );
682    }
683}