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}