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