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