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