Skip to main content

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