Skip to main content

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}