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