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}