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
181/// The pre-handshake state every constructor on [`AnvilSession`]
182/// builds before driving russh. Factoring it out keeps `connect`,
183/// `connect_via_proxy_command`, and `connect_via_jump_hosts` (M13.4)
184/// in lock-step on host-key handling and the `auth_banner` /
185/// `verified_fingerprint` mutexes the public getters expose.
186struct HandlerPieces {
187 russh_cfg: Arc<client::Config>,
188 handler: GitwayHandler,
189 auth_banner: Arc<Mutex<Option<String>>>,
190 verified_fingerprint: Arc<Mutex<Option<String>>>,
191}
192
193impl AnvilSession {
194 // ── Construction ─────────────────────────────────────────────────────────
195
196 /// Builds the russh config + handler used by every constructor.
197 ///
198 /// Centralises host-key fingerprint lookup (with the
199 /// [`StrictHostKeyChecking::AcceptNew`] tolerance for unknown hosts
200 /// when a writable `custom_known_hosts` path is set) and the shared
201 /// `auth_banner` / `verified_fingerprint` mutex pair.
202 fn build_handler_pieces(config: &AnvilConfig) -> Result<HandlerPieces, AnvilError> {
203 let russh_cfg = Arc::new(build_russh_config(config.inactivity_timeout));
204 // For StrictHostKeyChecking=AcceptNew with a writable known_hosts
205 // path, an empty fingerprint set is acceptable — the handler will
206 // record the first fingerprint it sees. Every other policy
207 // (Yes / No) treats the lookup error as fatal as before.
208 let fingerprints =
209 match hostkey::fingerprints_for_host(&config.host, &config.custom_known_hosts) {
210 Ok(fps) => fps,
211 Err(e) => {
212 if matches!(
213 config.strict_host_key_checking,
214 StrictHostKeyChecking::AcceptNew
215 ) && config.custom_known_hosts.is_some()
216 {
217 log::info!(
218 "session: no fingerprints known for {}; \
219 accept-new will record on first connection",
220 config.host,
221 );
222 Vec::new()
223 } else {
224 return Err(e);
225 }
226 }
227 };
228 let auth_banner = Arc::new(Mutex::new(None));
229 let verified_fingerprint = Arc::new(Mutex::new(None));
230
231 let handler = GitwayHandler {
232 fingerprints,
233 policy: config.strict_host_key_checking,
234 host: config.host.clone(),
235 custom_known_hosts: config.custom_known_hosts.clone(),
236 auth_banner: Arc::clone(&auth_banner),
237 verified_fingerprint: Arc::clone(&verified_fingerprint),
238 };
239
240 Ok(HandlerPieces {
241 russh_cfg,
242 handler,
243 auth_banner,
244 verified_fingerprint,
245 })
246 }
247
248 /// Establishes a TCP connection to the host in `config` and completes the
249 /// SSH handshake (including host-key verification).
250 ///
251 /// Does **not** authenticate; call [`authenticate`](Self::authenticate) or
252 /// [`authenticate_best`](Self::authenticate_best) after this.
253 ///
254 /// # Errors
255 ///
256 /// Returns an error on network failure or if the server's host key does not
257 /// match any pinned fingerprint.
258 pub async fn connect(config: &AnvilConfig) -> Result<Self, AnvilError> {
259 let pieces = Self::build_handler_pieces(config)?;
260
261 log::debug!("session: connecting to {}:{}", config.host, config.port);
262
263 let handle = client::connect(
264 pieces.russh_cfg,
265 (config.host.as_str(), config.port),
266 pieces.handler,
267 )
268 .await?;
269
270 log::debug!("session: SSH handshake complete with {}", config.host);
271
272 Ok(Self {
273 handle,
274 auth_banner: pieces.auth_banner,
275 verified_fingerprint: pieces.verified_fingerprint,
276 })
277 }
278
279 /// Establishes the SSH session through a chain of `ProxyJump`
280 /// bastion hops (FR-56).
281 ///
282 /// For each hop in `jumps`:
283 ///
284 /// 1. Build a per-hop [`AnvilConfig`] from the [`JumpHost`] fields,
285 /// inheriting `strict_host_key_checking`, `custom_known_hosts`,
286 /// and `verbose` from the primary `config`. Per-hop user and
287 /// `identity_files` come from the [`JumpHost`] when set, else
288 /// from the primary config.
289 /// 2. Connect: the *first* hop uses [`russh::client::connect`] over
290 /// TCP; subsequent hops use the *previous* hop's
291 /// `direct-tcpip` channel as the underlying transport via
292 /// [`russh::client::connect_stream`].
293 /// 3. Run host-key verification — every hop runs the full
294 /// [`GitwayHandler::check_server_key`] path independently
295 /// (NFR-17: failure at hop `n+1` aborts the entire chain;
296 /// no partial-success path).
297 /// 4. Authenticate the hop with [`AnvilSession::authenticate_best`]
298 /// so the chain can open `direct-tcpip` to the next hop.
299 ///
300 /// After the loop, the *last* bastion's handle is used to open
301 /// `direct-tcpip` to the primary `config.host` / `config.port`,
302 /// and the resulting [`ChannelStream`] becomes the SSH transport
303 /// for the final session this method returns.
304 ///
305 /// # Per-hop `ssh_config`
306 ///
307 /// This method does NOT re-resolve `ssh_config` per hop — that
308 /// requires the caller's [`SshConfigPaths`], which the session
309 /// module deliberately does not depend on. The CLI dispatcher
310 /// (M13.6) is responsible for populating
311 /// [`JumpHost::identity_files`] (and any other per-hop overrides)
312 /// from per-hop [`crate::ssh_config::resolve`] calls before
313 /// invoking this method.
314 ///
315 /// # Errors
316 /// Returns the first error encountered. An empty `jumps` slice is
317 /// rejected with a clear message — callers should use
318 /// [`Self::connect`] when no chain is in play. Authentication
319 /// failures at any intermediate hop terminate the whole chain.
320 /// `ChannelStream`-based transport errors propagate via the
321 /// usual russh / [`AnvilError`] mapping.
322 ///
323 /// # Panics
324 /// Does not panic. An internal `expect` fires only on a logic bug
325 /// (the empty-`jumps` check at the top of the function would have
326 /// already returned).
327 pub async fn connect_via_jump_hosts(
328 config: &AnvilConfig,
329 jumps: &[crate::proxy::JumpHost],
330 ) -> Result<Self, AnvilError> {
331 if jumps.is_empty() {
332 return Err(AnvilError::invalid_config(
333 "ProxyJump: empty jump-host list; call AnvilSession::connect instead",
334 ));
335 }
336
337 log::debug!(
338 "session: connecting to {}:{} via {} bastion hop(s)",
339 config.host,
340 config.port,
341 jumps.len(),
342 );
343
344 let mut prev_handle: Option<client::Handle<GitwayHandler>> = None;
345
346 for (idx, hop) in jumps.iter().enumerate() {
347 let hop_config = jump_to_config(hop, config);
348 let pieces = Self::build_handler_pieces(&hop_config)?;
349
350 log::debug!(
351 "session: bastion hop {}/{}: connecting to {}:{}",
352 idx + 1,
353 jumps.len(),
354 hop.host,
355 hop.port,
356 );
357
358 let handle = match prev_handle.take() {
359 None => {
360 // First hop: regular TCP connect.
361 client::connect(
362 pieces.russh_cfg,
363 (hop.host.as_str(), hop.port),
364 pieces.handler,
365 )
366 .await?
367 }
368 Some(prev) => {
369 // Subsequent hop: open `direct-tcpip` on the
370 // previous bastion, treat the channel as the
371 // transport for the next session.
372 let channel = prev
373 .channel_open_direct_tcpip(
374 hop.host.clone(),
375 u32::from(hop.port),
376 "127.0.0.1",
377 0_u32,
378 )
379 .await?;
380 client::connect_stream(pieces.russh_cfg, channel.into_stream(), pieces.handler)
381 .await?
382 }
383 };
384
385 // Authenticate this bastion so we can open the next hop's
386 // direct-tcpip channel through it. Wrap in a temporary
387 // AnvilSession to reuse the existing auth surface.
388 let mut hop_session = Self {
389 handle,
390 auth_banner: pieces.auth_banner,
391 verified_fingerprint: pieces.verified_fingerprint,
392 };
393 hop_session
394 .authenticate_best(&hop_config)
395 .await
396 .map_err(|e| {
397 e.with_hint(format!(
398 "ProxyJump: authentication failed at bastion hop {}/{} ({}:{})",
399 idx + 1,
400 jumps.len(),
401 hop.host,
402 hop.port,
403 ))
404 })?;
405
406 prev_handle = Some(hop_session.handle);
407 }
408
409 // Final hop: open `direct-tcpip` from the last bastion to the
410 // target, run the SSH handshake over that channel.
411 let prev = prev_handle
412 .expect("loop body ran at least once because jumps is non-empty (checked above)");
413
414 let target_pieces = Self::build_handler_pieces(config)?;
415
416 log::debug!(
417 "session: connecting to target {}:{} via last bastion",
418 config.host,
419 config.port,
420 );
421
422 let channel = prev
423 .channel_open_direct_tcpip(
424 config.host.clone(),
425 u32::from(config.port),
426 "127.0.0.1",
427 0_u32,
428 )
429 .await?;
430 let final_handle = client::connect_stream(
431 target_pieces.russh_cfg,
432 channel.into_stream(),
433 target_pieces.handler,
434 )
435 .await?;
436
437 log::debug!(
438 "session: SSH handshake complete with {} (via {} bastion hop(s))",
439 config.host,
440 jumps.len(),
441 );
442
443 Ok(Self {
444 handle: final_handle,
445 auth_banner: target_pieces.auth_banner,
446 verified_fingerprint: target_pieces.verified_fingerprint,
447 })
448 }
449
450 /// Establishes the SSH session over a child process spawned from a
451 /// `ProxyCommand` template (FR-55).
452 ///
453 /// `proxy_command_template` is the raw template (typically from
454 /// [`crate::ssh_config::ResolvedSshConfig::proxy_command`] or a CLI
455 /// override). `%h`, `%p`, `%r`, `%n`, and `%%` are expanded against
456 /// `config.host`, `config.port`, `config.username`, and `alias`
457 /// respectively before the platform shell (`sh -c` / `cmd /C`)
458 /// spawns the command. The child's stdin/stdout become the SSH
459 /// transport via [`russh::client::connect_stream`].
460 ///
461 /// `alias` is the original argument the user typed before
462 /// `HostName` resolution — it powers the `%n` token. Pass
463 /// `config.host` if you do not track the alias separately.
464 ///
465 /// The literal value `"none"` (case-insensitive) is recognized as
466 /// the FR-59 disable sentinel: this method returns an error
467 /// directing the caller to use [`Self::connect`] instead. In
468 /// practice the caller's dispatcher should never invoke this
469 /// method in that case, but the guard keeps the spawn path safe
470 /// against accidental "none" input.
471 ///
472 /// # Errors
473 /// Returns an error on shell-spawn failure, on a host-key
474 /// mismatch, or on any russh handshake failure.
475 pub async fn connect_via_proxy_command(
476 config: &AnvilConfig,
477 proxy_command_template: &str,
478 alias: &str,
479 ) -> Result<Self, AnvilError> {
480 if proxy_command_template.eq_ignore_ascii_case("none") {
481 return Err(AnvilError::invalid_config(
482 "ProxyCommand=none is the disable sentinel; \
483 call AnvilSession::connect instead",
484 ));
485 }
486
487 let pieces = Self::build_handler_pieces(config)?;
488
489 log::debug!(
490 "session: connecting to {} via ProxyCommand template `{proxy_command_template}`",
491 config.host,
492 );
493
494 let stream = crate::proxy::command::spawn_proxy_command(
495 proxy_command_template,
496 &config.host,
497 config.port,
498 &config.username,
499 alias,
500 )?;
501
502 let handle = client::connect_stream(pieces.russh_cfg, stream, pieces.handler).await?;
503
504 log::debug!(
505 "session: SSH handshake complete with {} (via ProxyCommand)",
506 config.host,
507 );
508
509 Ok(Self {
510 handle,
511 auth_banner: pieces.auth_banner,
512 verified_fingerprint: pieces.verified_fingerprint,
513 })
514 }
515
516 // ── Authentication ────────────────────────────────────────────────────────
517
518 /// Authenticates with an explicit key.
519 ///
520 /// Use [`authenticate_best`] to let the library discover the key
521 /// automatically.
522 ///
523 /// # Errors
524 ///
525 /// Returns an error on SSH protocol failures. Returns
526 /// [`AnvilError::is_authentication_failed`] when the server accepts the
527 /// exchange but rejects the key.
528 pub async fn authenticate(
529 &mut self,
530 username: &str,
531 key: PrivateKeyWithHashAlg,
532 ) -> Result<(), AnvilError> {
533 log::debug!("session: authenticating as {username}");
534
535 let result = self.handle.authenticate_publickey(username, key).await?;
536
537 if result.success() {
538 log::debug!("session: authentication succeeded for {username}");
539 Ok(())
540 } else {
541 Err(AnvilError::authentication_failed())
542 }
543 }
544
545 /// Authenticates with a private key and an accompanying OpenSSH certificate
546 /// (FR-12).
547 ///
548 /// The certificate is presented to the server in place of the raw public
549 /// key. This is typically used with organisation-issued certificates that
550 /// grant access without requiring the public key to be listed in
551 /// `authorized_keys`.
552 ///
553 /// # Errors
554 ///
555 /// Returns an error on SSH protocol failures or if the server rejects the
556 /// certificate.
557 pub async fn authenticate_with_cert(
558 &mut self,
559 username: &str,
560 key: russh::keys::PrivateKey,
561 cert: russh::keys::Certificate,
562 ) -> Result<(), AnvilError> {
563 log::debug!("session: authenticating as {username} with OpenSSH certificate");
564
565 let result = self
566 .handle
567 .authenticate_openssh_cert(username, Arc::new(key), cert)
568 .await?;
569
570 if result.success() {
571 log::debug!("session: certificate authentication succeeded for {username}");
572 Ok(())
573 } else {
574 Err(AnvilError::authentication_failed())
575 }
576 }
577
578 /// Discovers the best available key and authenticates using it.
579 ///
580 /// Priority order (FR-9):
581 /// 1. Explicit `--identity` path from config.
582 /// 2. Default `.ssh` paths (`id_ed25519` → `id_ecdsa` → `id_rsa`).
583 /// 3. SSH agent via `$SSH_AUTH_SOCK` (Unix only).
584 ///
585 /// If a certificate path is configured in `config.cert_file`, certificate
586 /// authentication (FR-12) is used instead of raw public-key authentication
587 /// for file-based keys.
588 ///
589 /// When the chosen key requires a passphrase this method returns an error
590 /// whose [`is_key_encrypted`](AnvilError::is_key_encrypted) predicate is
591 /// `true`; the caller (CLI layer) should then prompt and call
592 /// [`authenticate_with_passphrase`](Self::authenticate_with_passphrase).
593 ///
594 /// # Errors
595 ///
596 /// Returns [`AnvilError::is_no_key_found`] when no key is available via
597 /// any discovery method.
598 pub async fn authenticate_best(&mut self, config: &AnvilConfig) -> Result<(), AnvilError> {
599 use crate::auth::{find_identity, wrap_key, IdentityResolution};
600
601 let resolution = find_identity(config)?;
602
603 match resolution {
604 IdentityResolution::Found { key, .. } => {
605 return self.auth_key_or_cert(config, key).await;
606 }
607 IdentityResolution::Encrypted { path } => {
608 log::debug!(
609 "session: key at {} is passphrase-protected; trying SSH agent first",
610 path.display()
611 );
612 // Try the agent before asking for a passphrase. The key may
613 // already be loaded via `ssh-add`, and a passphrase prompt is
614 // impossible when gitway is spawned by Git without a terminal.
615 #[cfg(unix)]
616 {
617 use crate::auth::connect_agent;
618 if let Some(conn) = connect_agent().await? {
619 match self.authenticate_with_agent(&config.username, conn).await {
620 Ok(()) => return Ok(()),
621 Err(e) if e.is_authentication_failed() => {
622 log::debug!(
623 "session: agent could not authenticate; \
624 will request passphrase for {}",
625 path.display()
626 );
627 }
628 Err(e) => return Err(e),
629 }
630 }
631 }
632 return Err(AnvilError::new(AnvilErrorKind::Keys(
633 russh::keys::Error::KeyIsEncrypted,
634 )));
635 }
636 IdentityResolution::NotFound => {
637 // Fall through to agent (below).
638 }
639 }
640
641 // Priority 3: SSH agent — reached only when no file-based key exists (FR-9).
642 #[cfg(unix)]
643 {
644 use crate::auth::connect_agent;
645 if let Some(conn) = connect_agent().await? {
646 return self.authenticate_with_agent(&config.username, conn).await;
647 }
648 }
649
650 // For RSA keys, ask the server which hash algorithm it prefers (FR-11).
651 // This branch is only reached when we must still try a key via wrap_key
652 // after exhausting the above — currently unused, but kept for clarity.
653 let _ = wrap_key; // suppress unused-import warning on non-Unix builds
654 Err(AnvilError::no_key_found())
655 }
656
657 /// Loads an encrypted key with `passphrase` and authenticates.
658 ///
659 /// Call this after [`authenticate_best`] returns an encrypted-key error
660 /// and the CLI has collected the passphrase from the terminal.
661 ///
662 /// If `config.cert_file` is set, certificate authentication is used
663 /// (FR-12).
664 ///
665 /// # Errors
666 ///
667 /// Returns an error if the passphrase is wrong or authentication fails.
668 pub async fn authenticate_with_passphrase(
669 &mut self,
670 config: &AnvilConfig,
671 path: &std::path::Path,
672 passphrase: &str,
673 ) -> Result<(), AnvilError> {
674 use crate::auth::load_encrypted_key;
675
676 let key = load_encrypted_key(path, passphrase)?;
677 self.auth_key_or_cert(config, key).await
678 }
679
680 /// Tries each identity held in `conn` until one succeeds or all are
681 /// exhausted.
682 ///
683 /// On Unix this is called automatically by [`authenticate_best`] when no
684 /// file-based key is found. For plain public-key identities the signing
685 /// challenge is forwarded to the agent; for certificate identities the
686 /// full certificate is presented alongside the agent-signed challenge.
687 ///
688 /// # Errors
689 ///
690 /// Returns [`AnvilError::is_authentication_failed`] if all identities are
691 /// rejected, or [`AnvilError::is_no_key_found`] if the agent was empty.
692 #[cfg(unix)]
693 pub async fn authenticate_with_agent(
694 &mut self,
695 username: &str,
696 mut conn: crate::auth::AgentConnection,
697 ) -> Result<(), AnvilError> {
698 use russh::keys::agent::AgentIdentity;
699
700 for identity in conn.identities.clone() {
701 let result = match &identity {
702 AgentIdentity::PublicKey { key, .. } => {
703 let hash_alg = if key.algorithm().is_rsa() {
704 self.handle
705 .best_supported_rsa_hash()
706 .await?
707 .flatten()
708 // Fall back to SHA-256 when the server offers no guidance (FR-11).
709 .or(Some(HashAlg::Sha256))
710 } else {
711 None
712 };
713 self.handle
714 .authenticate_publickey_with(
715 username,
716 key.clone(),
717 hash_alg,
718 &mut conn.client,
719 )
720 .await
721 .map_err(AnvilError::from)
722 }
723 AgentIdentity::Certificate { certificate, .. } => self
724 .handle
725 .authenticate_certificate_with(
726 username,
727 certificate.clone(),
728 None,
729 &mut conn.client,
730 )
731 .await
732 .map_err(AnvilError::from),
733 };
734
735 match result? {
736 r if r.success() => {
737 log::debug!("session: agent authentication succeeded");
738 return Ok(());
739 }
740 _ => {
741 log::debug!("session: agent identity rejected; trying next");
742 }
743 }
744 }
745
746 Err(AnvilError::no_key_found())
747 }
748
749 // ── Exec / relay ──────────────────────────────────────────────────────────
750
751 /// Opens a session channel, executes `command`, and relays stdio
752 /// bidirectionally until the remote process exits.
753 ///
754 /// Returns the remote exit code (FR-16). Exit-via-signal returns
755 /// `128 + signal_number` (FR-17).
756 ///
757 /// # Errors
758 ///
759 /// Returns an error on channel open failure or SSH protocol errors.
760 pub async fn exec(&mut self, command: &str) -> Result<u32, AnvilError> {
761 log::debug!("session: opening exec channel for '{command}'");
762
763 let channel = self.handle.channel_open_session().await?;
764 channel.exec(true, command).await?;
765
766 let exit_code = relay::relay_channel(channel).await?;
767
768 log::debug!("session: command '{command}' exited with code {exit_code}");
769
770 Ok(exit_code)
771 }
772
773 // ── Lifecycle ─────────────────────────────────────────────────────────────
774
775 /// Sends a graceful `SSH_MSG_DISCONNECT` and closes the connection.
776 ///
777 /// # Errors
778 ///
779 /// Returns an error if the disconnect message cannot be sent.
780 pub async fn close(self) -> Result<(), AnvilError> {
781 self.handle
782 .disconnect(Disconnect::ByApplication, "", "English")
783 .await?;
784 Ok(())
785 }
786
787 // ── Accessors ─────────────────────────────────────────────────────────────
788
789 /// Returns the authentication banner last received from the server (if any).
790 ///
791 /// For GitHub.com this contains the "Hi <user>!" welcome message.
792 ///
793 /// # Panics
794 ///
795 /// Panics if the internal mutex is poisoned, which can only occur if another
796 /// thread panicked while holding the lock — a programming error.
797 #[must_use]
798 pub fn auth_banner(&self) -> Option<String> {
799 self.auth_banner
800 .lock()
801 .expect("auth_banner lock is not poisoned")
802 .clone()
803 }
804
805 /// Returns the SHA-256 fingerprint of the server key that was verified.
806 ///
807 /// Available after a successful [`connect`](Self::connect). Returns `None`
808 /// when host-key verification was skipped (`--insecure-skip-host-check`).
809 ///
810 /// # Panics
811 ///
812 /// Panics if the internal mutex is poisoned — a programming error.
813 #[must_use]
814 pub fn verified_fingerprint(&self) -> Option<String> {
815 self.verified_fingerprint
816 .lock()
817 .expect("verified_fingerprint lock is not poisoned")
818 .clone()
819 }
820
821 // ── Internal helpers ──────────────────────────────────────────────────────
822
823 /// Authenticates with `key`, using certificate auth if `config.cert_file`
824 /// is set (FR-12), otherwise plain public-key auth (FR-11).
825 async fn auth_key_or_cert(
826 &mut self,
827 config: &AnvilConfig,
828 key: russh::keys::PrivateKey,
829 ) -> Result<(), AnvilError> {
830 use crate::auth::{load_cert, wrap_key};
831
832 if let Some(ref cert_path) = config.cert_file {
833 let cert = load_cert(cert_path)?;
834 return self
835 .authenticate_with_cert(&config.username, key, cert)
836 .await;
837 }
838
839 // For RSA keys, ask the server which hash algorithm it prefers (FR-11).
840 let rsa_hash = if key.algorithm().is_rsa() {
841 self.handle
842 .best_supported_rsa_hash()
843 .await?
844 .flatten()
845 .or(Some(HashAlg::Sha256))
846 } else {
847 None
848 };
849
850 let wrapped = wrap_key(key, rsa_hash);
851 self.authenticate(&config.username, wrapped).await
852 }
853}
854
855// ── russh config builder ──────────────────────────────────────────────────────
856
857/// Constructs a russh [`client::Config`] with Gitway's preferred algorithms.
858///
859/// Algorithm preferences (FR-2, FR-3, FR-4):
860/// - Key exchange: `curve25519-sha256` (RFC 8731) with
861/// `curve25519-sha256@libssh.org` as fallback.
862/// - Cipher: `chacha20-poly1305@openssh.com`.
863/// - `ext-info-c` advertises server-sig-algs extension support.
864fn build_russh_config(inactivity_timeout: Duration) -> client::Config {
865 client::Config {
866 // 60 s matches GitHub's server-side idle threshold.
867 // Lowering below ~10 s risks spurious timeouts on high-latency links.
868 inactivity_timeout: Some(inactivity_timeout),
869 preferred: Preferred {
870 kex: Cow::Owned(vec![
871 kex::CURVE25519, // curve25519-sha256 (RFC 8731)
872 kex::CURVE25519_PRE_RFC_8731, // curve25519-sha256@libssh.org
873 kex::EXTENSION_SUPPORT_AS_CLIENT, // ext-info-c (FR-4)
874 ]),
875 cipher: Cow::Owned(vec![
876 cipher::CHACHA20_POLY1305, // chacha20-poly1305@openssh.com (FR-3)
877 ]),
878 ..Default::default()
879 },
880 ..Default::default()
881 }
882}
883
884// ── Jump-host helper (M13.4) ─────────────────────────────────────────────────
885
886/// Builds the per-hop [`AnvilConfig`] used inside
887/// `AnvilSession::connect_via_jump_hosts`.
888///
889/// Inherits security knobs — `strict_host_key_checking`,
890/// `custom_known_hosts`, `verbose` — from the *primary* config so a
891/// user's connection-wide policy (e.g. `--insecure-skip-host-check`)
892/// applies to every hop. Per-hop fields (`user`, `identity_files`)
893/// come from the [`crate::proxy::JumpHost`] when set, else from the
894/// primary config: a CLI `--user alice` thus propagates to every
895/// bastion that did not override the user in its own `Host` block.
896fn jump_to_config(hop: &crate::proxy::JumpHost, primary: &AnvilConfig) -> AnvilConfig {
897 let mut builder = AnvilConfig::builder(&hop.host)
898 .port(hop.port)
899 .strict_host_key_checking(primary.strict_host_key_checking)
900 .verbose(primary.verbose);
901
902 let username = hop.user.clone().unwrap_or_else(|| primary.username.clone());
903 builder = builder.username(username);
904
905 let identity_files: Vec<_> = if hop.identity_files.is_empty() {
906 primary.identity_files.clone()
907 } else {
908 hop.identity_files.clone()
909 };
910 builder = builder.identity_files(identity_files);
911
912 if let Some(p) = &primary.custom_known_hosts {
913 builder = builder.custom_known_hosts(p.clone());
914 }
915
916 builder.build()
917}
918
919// ── Tests ─────────────────────────────────────────────────────────────────────
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924
925 // ── NFR-6: legacy algorithm exclusion ────────────────────────────────────
926
927 /// 3DES-CBC must never appear in the negotiated cipher list (NFR-6).
928 ///
929 /// Our explicit cipher override contains only chacha20-poly1305, so 3DES
930 /// cannot be selected even if the server offers it.
931 #[test]
932 fn config_cipher_excludes_3des() {
933 let config = build_russh_config(Duration::from_secs(60));
934 let found = config
935 .preferred
936 .cipher
937 .iter()
938 .any(|c| c.as_ref() == "3des-cbc");
939 assert!(
940 !found,
941 "3DES-CBC must not appear in the cipher list (NFR-6)"
942 );
943 }
944
945 /// DSA must never appear in the key-algorithm list (NFR-6).
946 ///
947 /// russh's `Preferred::DEFAULT` already omits DSA; this test locks that
948 /// invariant so a russh upgrade cannot silently re-introduce it.
949 #[test]
950 fn config_key_algorithms_exclude_dsa() {
951 use russh::keys::Algorithm;
952
953 let config = build_russh_config(Duration::from_secs(60));
954 assert!(
955 !config.preferred.key.contains(&Algorithm::Dsa),
956 "DSA must not appear in the key-algorithm list (NFR-6)"
957 );
958 }
959
960 // ── FR-2 / FR-3 positive assertions ─────────────────────────────────────
961
962 /// curve25519-sha256 must be in the kex list (FR-2).
963 #[test]
964 fn config_kex_includes_curve25519() {
965 let config = build_russh_config(Duration::from_secs(60));
966 let found = config
967 .preferred
968 .kex
969 .iter()
970 .any(|k| k.as_ref() == "curve25519-sha256");
971 assert!(found, "curve25519-sha256 must be in the kex list (FR-2)");
972 }
973
974 /// chacha20-poly1305@openssh.com must be in the cipher list (FR-3).
975 #[test]
976 fn config_cipher_includes_chacha20_poly1305() {
977 let config = build_russh_config(Duration::from_secs(60));
978 let found = config
979 .preferred
980 .cipher
981 .iter()
982 .any(|c| c.as_ref() == "chacha20-poly1305@openssh.com");
983 assert!(
984 found,
985 "chacha20-poly1305@openssh.com must be in the cipher list (FR-3)"
986 );
987 }
988}