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}