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}