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