//! Storage-owned SSH authentication and strict known-host verification. use std::{ borrow::Cow, fs, future::Future, io::{self, Read, Write}, path::{Path, PathBuf}, sync::{Arc, Mutex}, time::Duration, }; use cap_std::{ambient_authority, fs::Dir}; use cap_tempfile::TempFile; use hmac::{Hmac, Mac as _}; use russh::{ ChannelMsg, Disconnect, Preferred, cipher, client, compression, kex, keys::{ HashAlg, PrivateKey, PublicKey, agent::client::AgentClient, key::PrivateKeyWithHashAlg, ssh_key::Algorithm, }, mac, }; use sha1::Sha1; use crate::{ config::{ GitRemote, RemoteEndpoint, SshEndpoint, SshFingerprint, SshIdentitySource, SshRemoteAuthentication, SshRepositoryPath, }, git::{GitError, GitOperationControl, GitProgressPhase, SshHostKey, SshPassphraseProvider}, }; const MAX_IDENTITY_BYTES: u64 = 1024 * 1024; const MAX_KNOWN_HOSTS_BYTES: u64 = 1024 * 1024; const MAX_KNOWN_HOST_LINE_BYTES: usize = 16 * 1024; const MAX_AGENT_IDENTITIES: usize = 64; const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30); const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); const CANCELLATION_POLL: Duration = Duration::from_millis(25); const CHANNEL_QUEUE_DEPTH: usize = 8; const MAX_CHANNEL_CHUNK: usize = 32 * 1024; const MAX_SSH_DIAGNOSTIC_BYTES: usize = 8 * 1024; static KNOWN_HOSTS_WRITE_LOCK: Mutex<()> = Mutex::new(()); pub(crate) type SshGitTransport = gix::protocol::transport::client::git::blocking_io::Connection; pub(crate) struct SshGitCommand { pub(crate) transport: SshGitTransport, pub(crate) completion: SshCommandCompletion, } pub(crate) struct SshRawGitCommand { pub(crate) stdout: SshStdout, pub(crate) stdin: SshStdin, pub(crate) completion: SshCommandCompletion, } pub(crate) type SshCommandCompletion = tokio::sync::oneshot::Receiver>; pub(crate) struct SshStdout { receiver: tokio::sync::mpsc::Receiver>, current: Vec, offset: usize, } impl Read for SshStdout { fn read(&mut self, output: &mut [u8]) -> io::Result { if output.is_empty() { return Ok(0); } while self.offset == self.current.len() { let Some(next) = self.receiver.blocking_recv() else { return Ok(0); }; self.current = next; self.offset = 0; } let count = output.len().min(self.current.len() - self.offset); output[..count].copy_from_slice(&self.current[self.offset..self.offset + count]); self.offset += count; Ok(count) } } pub(crate) struct SshStdin { sender: tokio::sync::mpsc::Sender>, } impl Write for SshStdin { fn write(&mut self, input: &[u8]) -> io::Result { if input.is_empty() { return Ok(0); } let count = input.len().min(MAX_CHANNEL_CHUNK); self.sender .blocking_send(input[..count].to_vec()) .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "SSH channel closed"))?; Ok(count) } fn flush(&mut self) -> io::Result<()> { Ok(()) } } /// An authenticated SSH connection. Git protocol channels are opened by the /// storage transport so callers cannot issue arbitrary commands. pub struct SshSession { runtime: tokio::runtime::Runtime, handle: client::Handle, } impl std::fmt::Debug for SshSession { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str("SshSession([AUTHENTICATED])") } } impl SshSession { pub fn connect( remote: &GitRemote, passphrases: &dyn SshPassphraseProvider, control: &GitOperationControl, ) -> Result { let RemoteEndpoint::Ssh(endpoint) = remote.endpoint() else { return Err(GitError::ForbiddenRemoteUrl); }; let connection = resolve_connection(endpoint, remote.ssh_authentication())?; control.report(GitProgressPhase::Validating)?; let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(|_| GitError::SshProtocolFailed)?; let host = connection.host.clone(); let port = connection.port; let known_hosts = connection.known_hosts.clone(); let verifier = HostVerifier { host: host.clone(), port, known_hosts, }; let config = client_config(); let handle = runtime.block_on(async { controlled( tokio::time::timeout( CONNECTION_TIMEOUT, client::connect(Arc::new(config), (host.as_str(), port), verifier), ), control, ) .await })?; let mut handle = match handle { Ok(Ok(handle)) => handle, Ok(Err(error)) => return Err(error.into_git()), Err(_) => return Err(GitError::SshProtocolFailed), }; control.report(GitProgressPhase::Authenticating)?; let user = connection.user.as_deref().unwrap_or("git"); let authenticated = match &connection.identity { SshIdentitySource::KeyFile(path) => runtime.block_on(authenticate_key_file( &mut handle, user, path, passphrases, control, ))?, SshIdentitySource::Agent { fingerprint, socket, } => runtime.block_on(authenticate_agent( &mut handle, user, fingerprint, socket.as_deref(), control, ))?, }; if !authenticated { return Err(GitError::SshAuthenticationRejected); } Ok(Self { runtime, handle }) } pub(crate) fn open_upload_pack( &self, remote: &GitRemote, control: &GitOperationControl, ) -> Result { let endpoint = remote .endpoint() .as_ssh() .ok_or(GitError::ForbiddenRemoteUrl)?; let SshRawGitCommand { stdout, stdin, completion, } = self.open_git_command(remote, GitService::UploadPack, control)?; let transport = gix::protocol::transport::client::git::blocking_io::Connection::new( stdout, stdin, gix::protocol::transport::Protocol::V1, endpoint.path().as_str().as_bytes().to_vec(), None::<(String, Option)>, gix::protocol::transport::client::git::ConnectMode::Process, false, ) .custom_url(Some(remote.url().into())); Ok(SshGitCommand { transport, completion, }) } pub(crate) fn open_receive_pack( &self, remote: &GitRemote, control: &GitOperationControl, ) -> Result { self.open_git_command(remote, GitService::ReceivePack, control) } fn open_git_command( &self, remote: &GitRemote, service: GitService, control: &GitOperationControl, ) -> Result { let endpoint = remote .endpoint() .as_ssh() .ok_or(GitError::ForbiddenRemoteUrl)?; let command = git_service_command(service, endpoint.path())?; let channel = self.runtime.block_on(async { controlled( tokio::time::timeout( COMMAND_TIMEOUT, open_command_channel(&self.handle, command, control), ), control, ) .await })?; let channel = match channel { Ok(Ok(channel)) => channel, Ok(Err(error)) => return Err(error), Err(_) => return Err(GitError::SshProtocolFailed), }; let (input_tx, input_rx) = tokio::sync::mpsc::channel(CHANNEL_QUEUE_DEPTH); let (output_tx, output_rx) = tokio::sync::mpsc::channel(CHANNEL_QUEUE_DEPTH); let (completion_tx, completion_rx) = tokio::sync::oneshot::channel(); let operation = control.clone(); self.runtime.spawn(async move { let result = pump_command(channel, input_rx, output_tx, &operation).await; let _ = completion_tx.send(result); }); Ok(SshRawGitCommand { stdout: SshStdout { receiver: output_rx, current: Vec::new(), offset: 0, }, stdin: SshStdin { sender: input_tx }, completion: completion_rx, }) } pub(crate) fn finish_command( &self, completion: SshCommandCompletion, control: &GitOperationControl, ) -> Result<(), GitError> { let result = self.runtime.block_on(async { controlled(tokio::time::timeout(COMMAND_TIMEOUT, completion), control).await })?; match result { Ok(Ok(result)) => result, Ok(Err(_)) | Err(_) => Err(GitError::SshProtocolFailed), } } pub fn close(self) -> Result<(), GitError> { self.runtime .block_on(self.handle.disconnect(Disconnect::ByApplication, "", "")) .map_err(|_| GitError::SshProtocolFailed) } } pub(crate) struct SshConnection { pub(crate) host: String, pub(crate) port: u16, user: Option, identity: SshIdentitySource, pub(crate) known_hosts: PathBuf, } pub(crate) fn resolve_connection( endpoint: &SshEndpoint, authentication: Option<&SshRemoteAuthentication>, ) -> Result { let home = home_directory().ok_or(GitError::SshAuthenticationNotConfigured)?; let mut connection = SshConnection { host: endpoint.host().to_owned(), port: endpoint.port(), user: endpoint.user().map(str::to_owned), identity: authentication .map(|authentication| authentication.identity().clone()) .unwrap_or_else(|| SshIdentitySource::KeyFile(home.join(".ssh/id_ed25519"))), known_hosts: authentication .map(|authentication| authentication.known_hosts_file().to_owned()) .unwrap_or_else(|| home.join(".ssh/known_hosts")), }; apply_openssh_config( &home.join(".ssh/config"), endpoint.host(), &home, authentication.is_none(), &mut connection, )?; Ok(connection) } fn home_directory() -> Option { #[cfg(target_os = "windows")] let home = std::env::var_os("USERPROFILE"); #[cfg(not(target_os = "windows"))] let home = std::env::var_os("HOME"); home.filter(|home| !home.is_empty()).map(PathBuf::from) } fn apply_openssh_config( path: &Path, alias: &str, home: &Path, use_identity: bool, connection: &mut SshConnection, ) -> Result<(), GitError> { let Ok(contents) = fs::read_to_string(path) else { return Ok(()); }; let mut active = true; let mut hostname_set = false; let mut user_set = connection.user.is_some(); let mut port_set = connection.port != 22; let mut identity_set = !use_identity; for line in contents.lines() { let Some(words) = shlex::split(line.split('#').next().unwrap_or_default()) else { return Err(GitError::SshAuthenticationNotConfigured); }; let Some((keyword, values)) = words.split_first() else { continue; }; if keyword.eq_ignore_ascii_case("host") { active = host_patterns_match(values, alias); } else if active && keyword.eq_ignore_ascii_case("hostname") && !hostname_set { if let Some(value) = values.first() { connection.host = value.clone(); hostname_set = true; } } else if active && keyword.eq_ignore_ascii_case("user") && !user_set { if let Some(value) = values.first() { connection.user = Some(value.clone()); user_set = true; } } else if active && keyword.eq_ignore_ascii_case("port") && !port_set { if let Some(value) = values.first() { connection.port = value .parse() .ok() .filter(|port| *port != 0) .ok_or(GitError::SshAuthenticationNotConfigured)?; port_set = true; } } else if active && keyword.eq_ignore_ascii_case("identityfile") && !identity_set && let Some(value) = values.first() { let expanded = value .replace("%h", &connection.host) .replace("%p", &connection.port.to_string()) .replace("%r", connection.user.as_deref().unwrap_or("git")); let identity = expanded .strip_prefix("~/") .map_or_else(|| PathBuf::from(&expanded), |path| home.join(path)); connection.identity = SshIdentitySource::KeyFile(identity); identity_set = true; } } Ok(()) } fn host_patterns_match(patterns: &[String], host: &str) -> bool { let mut matched = false; for pattern in patterns { let (negated, pattern) = pattern .strip_prefix('!') .map_or((false, pattern.as_str()), |pattern| (true, pattern)); if wildcard_match(pattern.as_bytes(), host.as_bytes()) { if negated { return false; } matched = true; } } matched } fn wildcard_match(pattern: &[u8], value: &[u8]) -> bool { let (mut pattern_index, mut value_index, mut star, mut retry) = (0, 0, None, 0); while value_index < value.len() { if pattern .get(pattern_index) .is_some_and(|byte| *byte == b'?' || byte.eq_ignore_ascii_case(&value[value_index])) { pattern_index += 1; value_index += 1; } else if pattern.get(pattern_index) == Some(&b'*') { star = Some(pattern_index); pattern_index += 1; retry = value_index; } else if let Some(star_index) = star { pattern_index = star_index + 1; retry += 1; value_index = retry; } else { return false; } } pattern[pattern_index..].iter().all(|byte| *byte == b'*') } fn client_config() -> client::Config { client::Config { inactivity_timeout: Some(CONNECTION_TIMEOUT), preferred: Preferred { kex: Cow::Owned(vec![ kex::MLKEM768X25519_SHA256, kex::CURVE25519, kex::CURVE25519_PRE_RFC_8731, kex::DH_GEX_SHA256, kex::DH_G18_SHA512, kex::DH_G17_SHA512, kex::DH_G16_SHA512, kex::DH_G15_SHA512, kex::DH_G14_SHA256, kex::EXTENSION_SUPPORT_AS_CLIENT, kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, ]), host_key_certificates: Cow::Borrowed(&[]), key: Cow::Owned(vec![ Algorithm::Ed25519, Algorithm::Ecdsa { curve: russh::keys::ssh_key::EcdsaCurve::NistP256, }, Algorithm::Ecdsa { curve: russh::keys::ssh_key::EcdsaCurve::NistP384, }, Algorithm::Ecdsa { curve: russh::keys::ssh_key::EcdsaCurve::NistP521, }, Algorithm::Rsa { hash: Some(HashAlg::Sha512), }, Algorithm::Rsa { hash: Some(HashAlg::Sha256), }, ]), cipher: Cow::Owned(vec![ cipher::CHACHA20_POLY1305, cipher::AES_256_GCM, cipher::AES_256_CTR, cipher::AES_192_CTR, cipher::AES_128_CTR, ]), mac: Cow::Owned(vec![ mac::HMAC_SHA512_ETM, mac::HMAC_SHA256_ETM, mac::HMAC_SHA512, mac::HMAC_SHA256, ]), compression: Cow::Owned(vec![compression::NONE]), }, ..client::Config::default() } } async fn open_command_channel( handle: &client::Handle, command: Vec, control: &GitOperationControl, ) -> Result, GitError> { let mut channel = handle .channel_open_session() .await .map_err(|_| GitError::SshProtocolFailed)?; channel .exec(true, command) .await .map_err(|_| GitError::SshProtocolFailed)?; let mut diagnostic = Vec::new(); loop { if control.is_cancelled() { let _ = channel.close().await; return Err(GitError::Cancelled); } match channel.wait().await { Some(ChannelMsg::Success) => return Ok(channel), Some(ChannelMsg::Failure) | Some(ChannelMsg::Close) | None => { return Err(remote_service_error(&diagnostic)); } Some(ChannelMsg::ExtendedData { data, .. }) => { extend_diagnostic(&mut diagnostic, &data); } Some(ChannelMsg::ExitStatus { .. }) | Some(ChannelMsg::ExitSignal { .. }) => { return Err(remote_service_error(&diagnostic)); } Some(ChannelMsg::Data { .. }) => return Err(GitError::GitProtocolFailed), Some(_) => {} } } } async fn pump_command( mut channel: russh::Channel, mut input: tokio::sync::mpsc::Receiver>, output: tokio::sync::mpsc::Sender>, control: &GitOperationControl, ) -> Result<(), GitError> { let mut diagnostic = Vec::new(); let mut exit_status = None; let mut input_open = true; let mut output_open = true; loop { tokio::select! { message = channel.wait() => match message { Some(ChannelMsg::Data { data }) => { if output_open && output.send(data.to_vec()).await.is_err() { output_open = false; } } Some(ChannelMsg::ExtendedData { data, .. }) => { extend_diagnostic(&mut diagnostic, &data); } Some(ChannelMsg::ExitStatus { exit_status: status }) => exit_status = Some(status), Some(ChannelMsg::ExitSignal { error_message, .. }) => { extend_diagnostic(&mut diagnostic, error_message.as_bytes()); exit_status = Some(u32::MAX); } Some(ChannelMsg::Eof) => {} Some(ChannelMsg::Close) | None => break, Some(ChannelMsg::Failure) => { exit_status = Some(u32::MAX); } Some(_) => {} }, outbound = input.recv(), if input_open => match outbound { Some(data) => channel .data_bytes(data) .await .map_err(|_| GitError::SshProtocolFailed)?, None => { input_open = false; channel.eof().await.map_err(|_| GitError::SshProtocolFailed)?; } }, () = tokio::time::sleep(CANCELLATION_POLL) => { if control.is_cancelled() { let _ = channel.close().await; return Err(GitError::Cancelled); } } } } if control.is_cancelled() { return Err(GitError::Cancelled); } match exit_status { Some(0) => Ok(()), _ => Err(remote_service_error(&diagnostic)), } } #[derive(Clone, Copy)] enum GitService { UploadPack, ReceivePack, } fn git_service_command(service: GitService, path: &SshRepositoryPath) -> Result, GitError> { let service = match service { GitService::UploadPack => "git-upload-pack", GitService::ReceivePack => "git-receive-pack", }; let path = shell_quote(path.as_str()); let command = format!("{service} {path}"); if command.len() > 64 * 1024 { return Err(GitError::ForbiddenRemoteUrl); } Ok(command.into_bytes()) } fn shell_quote(value: &str) -> String { let mut quoted = String::with_capacity(value.len() + 2); quoted.push('\''); for character in value.chars() { if character == '\'' { quoted.push_str("'\\''"); } else { quoted.push(character); } } quoted.push('\''); quoted } fn extend_diagnostic(output: &mut Vec, input: &[u8]) { let remaining = MAX_SSH_DIAGNOSTIC_BYTES.saturating_sub(output.len()); output.extend_from_slice(&input[..input.len().min(remaining)]); } fn remote_service_error(diagnostic: &[u8]) -> GitError { let diagnostic = String::from_utf8_lossy(diagnostic) .chars() .map(|character| { if character.is_control() { ' ' } else { character } }) .collect::() .split_whitespace() .collect::>() .join(" "); GitError::SshRemoteServiceFailed { diagnostic } } /// Persist a host key only after the caller has confirmed an unknown-key error. /// Existing changed keys are never replaced. pub fn persist_confirmed_host( known_hosts_file: &Path, host_key: &SshHostKey, ) -> Result<(), GitError> { let _write = KNOWN_HOSTS_WRITE_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let public_key = PublicKey::from_openssh(host_key.encoded()).map_err(|_| GitError::SshProtocolFailed)?; let observed = ssh_host_key(host_key.host(), host_key.port(), &public_key)?; if &observed != host_key { return Err(GitError::SshProtocolFailed); } match verify_known_host( known_hosts_file, host_key.host(), host_key.port(), &public_key, )? { HostStatus::Trusted => return Ok(()), HostStatus::Changed { line } => { return Err(GitError::ChangedSshHostKey { host_key: Box::new(host_key.clone()), line, }); } HostStatus::Unknown => {} } let parent = known_hosts_file .parent() .ok_or_else(|| GitError::SshKnownHostsUnavailable { path: known_hosts_file.to_owned(), })?; fs::create_dir_all(parent).map_err(|_| GitError::SshKnownHostsUnavailable { path: known_hosts_file.to_owned(), })?; set_private_directory(parent, known_hosts_file)?; let mut contents = read_known_hosts(known_hosts_file)?; if !contents.is_empty() && !contents.ends_with(b"\n") { contents.push(b'\n'); } let host = if host_key.port() == 22 { host_key.host().to_owned() } else { format!("[{}]:{}", host_key.host(), host_key.port()) }; let line = format!("{host} {}\n", host_key.encoded()); if contents.len().saturating_add(line.len()) > MAX_KNOWN_HOSTS_BYTES as usize { return Err(GitError::SshKnownHostsUnavailable { path: known_hosts_file.to_owned(), }); } contents.extend_from_slice(line.as_bytes()); replace_known_hosts(known_hosts_file, &contents) } #[derive(Debug)] enum ClientError { Git(GitError), Protocol, } impl ClientError { fn into_git(self) -> GitError { match self { Self::Git(error) => error, Self::Protocol => GitError::SshProtocolFailed, } } } impl From for ClientError { fn from(error: russh::Error) -> Self { match error { russh::Error::IO(_) | russh::Error::Disconnect | russh::Error::HUP | russh::Error::ConnectionTimeout | russh::Error::KeepaliveTimeout | russh::Error::InactivityTimeout => Self::Git(GitError::NetworkUnavailable), russh::Error::NoCommonAlgo { kind, .. } => { Self::Git(GitError::SshUnsupportedAlgorithm { algorithm: format!("SSH {kind:?}"), }) } _ => Self::Protocol, } } } struct HostVerifier { host: String, port: u16, known_hosts: PathBuf, } impl client::Handler for HostVerifier { type Error = ClientError; async fn check_server_key( &mut self, server_key: &russh::keys::PublicKeyOrCertificate, ) -> Result { if server_key.certificate().is_some() { return Err(ClientError::Git(GitError::SshUnsupportedAlgorithm { algorithm: "OpenSSH host certificate".to_owned(), })); } let public_key = server_key.public_key(); ensure_supported_key(&public_key).map_err(ClientError::Git)?; let host_key = ssh_host_key(&self.host, self.port, &public_key).map_err(ClientError::Git)?; match verify_known_host(&self.known_hosts, &self.host, self.port, &public_key) .map_err(ClientError::Git)? { HostStatus::Trusted => Ok(true), HostStatus::Unknown => Err(ClientError::Git(GitError::UnknownSshHostKey { host_key: Box::new(host_key), })), HostStatus::Changed { line } => Err(ClientError::Git(GitError::ChangedSshHostKey { host_key: Box::new(host_key), line, })), } } } async fn authenticate_key_file( handle: &mut client::Handle, user: &str, path: &Path, passphrases: &dyn SshPassphraseProvider, control: &GitOperationControl, ) -> Result { let metadata = fs::symlink_metadata(path).map_err(|_| GitError::SshIdentityMissing { path: path.to_owned(), })?; if metadata.file_type().is_symlink() { return Err(GitError::SshIdentityInvalid { path: path.to_owned(), }); } let encoded = read_bounded_file(path, MAX_IDENTITY_BYTES).map_err(|_| GitError::SshIdentityInvalid { path: path.to_owned(), })?; let mut key = PrivateKey::from_openssh(&encoded).map_err(|_| GitError::SshIdentityInvalid { path: path.to_owned(), })?; ensure_supported_key(key.public_key())?; let fingerprint = fingerprint(key.public_key())?; if key.is_encrypted() { let passphrase = passphrases.ssh_key_passphrase(&fingerprint)?; key = key .decrypt(passphrase.expose()) .map_err(|_| GitError::SshKeyPassphraseRejected { fingerprint: fingerprint.clone(), })?; } let hash = rsa_hash(handle, key.public_key(), control).await?; let result = controlled( handle.authenticate_publickey(user, PrivateKeyWithHashAlg::new(Arc::new(key), hash)), control, ) .await? .map_err(|_| GitError::SshProtocolFailed)?; Ok(result.success()) } async fn authenticate_agent( handle: &mut client::Handle, user: &str, selected: &SshFingerprint, socket: Option<&Path>, control: &GitOperationControl, ) -> Result { let mut agent = connect_agent(socket).await?; let identities = controlled(agent.request_identities(), control) .await? .map_err(|_| GitError::SshAgentUnavailable)?; if identities.len() > MAX_AGENT_IDENTITIES { return Err(GitError::SshAgentUnavailable); } let identity = identities .into_iter() .find(|identity| { fingerprint(identity.public_key().as_ref()) .as_ref() .is_ok_and(|fingerprint| fingerprint == selected) }) .ok_or_else(|| GitError::SshAgentIdentityMissing { fingerprint: selected.clone(), })?; let public_key = identity.public_key().into_owned(); ensure_supported_key(&public_key)?; let hash = rsa_hash(handle, &public_key, control).await?; let result = controlled( handle.authenticate_publickey_with(user, public_key, hash, &mut agent), control, ) .await? .map_err(|_| GitError::SshAgentUnavailable)?; Ok(result.success()) } #[cfg(unix)] async fn connect_agent( socket: Option<&Path>, ) -> Result>, GitError> { let client = match socket { Some(path) => AgentClient::connect_uds(path).await, None => AgentClient::connect_env().await, } .map_err(|_| GitError::SshAgentUnavailable)?; Ok(client.dynamic()) } #[cfg(windows)] async fn connect_agent( socket: Option<&Path>, ) -> Result>, GitError> { match socket { Some(path) => AgentClient::connect_named_pipe(path) .await .map(AgentClient::dynamic), None => AgentClient::connect_pageant() .await .map(AgentClient::dynamic), } .map_err(|_| GitError::SshAgentUnavailable) } async fn rsa_hash( handle: &client::Handle, key: &PublicKey, control: &GitOperationControl, ) -> Result, GitError> { if !matches!(key.algorithm(), Algorithm::Rsa { .. }) { return Ok(None); } let supported = controlled(handle.best_supported_rsa_hash(), control) .await? .map_err(|_| GitError::SshProtocolFailed)?; match supported { Some(Some(hash)) => Ok(Some(hash)), Some(None) => Err(GitError::SshUnsupportedAlgorithm { algorithm: "ssh-rsa/SHA-1".to_owned(), }), None => Ok(Some(HashAlg::Sha512)), } } async fn controlled( future: F, control: &GitOperationControl, ) -> Result, GitError> where F: Future>, { tokio::pin!(future); loop { tokio::select! { result = &mut future => return Ok(result), () = tokio::time::sleep(CANCELLATION_POLL) => { if control.is_cancelled() { return Err(GitError::Cancelled); } } } } } fn ensure_supported_key(key: &PublicKey) -> Result<(), GitError> { match key.algorithm() { Algorithm::Ed25519 | Algorithm::Ecdsa { .. } | Algorithm::Rsa { .. } => Ok(()), algorithm => Err(GitError::SshUnsupportedAlgorithm { algorithm: algorithm.to_string(), }), } } fn fingerprint(key: &PublicKey) -> Result { SshFingerprint::parse(key.fingerprint(HashAlg::Sha256).to_string()) .map_err(|_| GitError::SshProtocolFailed) } fn ssh_host_key(host: &str, port: u16, key: &PublicKey) -> Result { Ok(SshHostKey::new( host.to_owned(), port, key.algorithm().to_string(), fingerprint(key)?, key.to_openssh().map_err(|_| GitError::SshProtocolFailed)?, )) } enum HostStatus { Trusted, Unknown, Changed { line: usize }, } fn verify_known_host( path: &Path, host: &str, port: u16, presented: &PublicKey, ) -> Result { let contents = read_known_hosts(path)?; let text = std::str::from_utf8(&contents).map_err(|_| GitError::SshKnownHostsUnavailable { path: path.to_owned(), })?; let target = if port == 22 { host.to_owned() } else { format!("[{host}]:{port}") }; let mut changed = None; for (index, line) in text.lines().enumerate() { if line.len() > MAX_KNOWN_HOST_LINE_BYTES { return Err(GitError::SshKnownHostsUnavailable { path: path.to_owned(), }); } let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let mut fields = line.split_whitespace(); let Some(first) = fields.next() else { continue }; let (marker, hosts) = if first.starts_with('@') { (Some(first), fields.next().unwrap_or_default()) } else { (None, first) }; let Some(algorithm) = fields.next() else { continue; }; let Some(encoded) = fields.next() else { continue; }; if !host_list_matches(&target, hosts) { continue; } if !matches!(marker, None | Some("@revoked") | Some("@cert-authority")) { return Err(GitError::SshKnownHostsUnavailable { path: path.to_owned(), }); } if marker == Some("@cert-authority") { continue; } let recorded = PublicKey::from_openssh(&format!("{algorithm} {encoded}")).map_err(|_| { GitError::SshKnownHostsUnavailable { path: path.to_owned(), } })?; if &recorded == presented && marker != Some("@revoked") { return Ok(HostStatus::Trusted); } if &recorded == presented || recorded.algorithm() == presented.algorithm() { changed.get_or_insert(index + 1); } } Ok(match changed { Some(line) => HostStatus::Changed { line }, None => HostStatus::Unknown, }) } fn host_list_matches(host: &str, patterns: &str) -> bool { let mut matched = false; for pattern in patterns.split(',') { let (negated, pattern) = pattern .strip_prefix('!') .map_or((false, pattern), |pattern| (true, pattern)); if host_matches(host, pattern) { if negated { return false; } matched = true; } } matched } fn host_matches(host: &str, pattern: &str) -> bool { let Some(hashed) = pattern.strip_prefix("|1|") else { return pattern == host; }; let mut fields = hashed.split('|'); let (Some(salt), Some(expected), None) = (fields.next(), fields.next(), fields.next()) else { return false; }; let Ok(salt) = data_encoding::BASE64.decode(salt.as_bytes()) else { return false; }; let Ok(expected) = data_encoding::BASE64.decode(expected.as_bytes()) else { return false; }; Hmac::::new_from_slice(&salt).is_ok_and(|mut mac| { mac.update(host.as_bytes()); mac.verify_slice(&expected).is_ok() }) } fn read_known_hosts(path: &Path) -> Result, GitError> { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(_) => { return Err(GitError::SshKnownHostsUnavailable { path: path.to_owned(), }); } }; if metadata.file_type().is_symlink() { return Err(GitError::SshKnownHostsUnavailable { path: path.to_owned(), }); } read_bounded_file(path, MAX_KNOWN_HOSTS_BYTES).map_err(|_| GitError::SshKnownHostsUnavailable { path: path.to_owned(), }) } fn read_bounded_file(path: &Path, maximum: u64) -> std::io::Result> { let file = fs::File::open(path)?; let metadata = file.metadata()?; if !metadata.is_file() || metadata.len() > maximum { return Err(std::io::Error::other("file exceeds security bounds")); } let mut contents = Vec::with_capacity(metadata.len() as usize); file.take(maximum + 1).read_to_end(&mut contents)?; if contents.len() > maximum as usize { return Err(std::io::Error::other("file changed while reading")); } Ok(contents) } fn replace_known_hosts(path: &Path, contents: &[u8]) -> Result<(), GitError> { let parent = path .parent() .ok_or_else(|| GitError::SshKnownHostsUnavailable { path: path.to_owned(), })?; let name = path .file_name() .ok_or_else(|| GitError::SshKnownHostsUnavailable { path: path.to_owned(), })?; let directory = Dir::open_ambient_dir(parent, ambient_authority()).map_err(|_| { GitError::SshKnownHostsUnavailable { path: path.to_owned(), } })?; if let Ok(metadata) = directory.symlink_metadata(name) && (metadata.file_type().is_symlink() || !metadata.is_file()) { return Err(GitError::SshKnownHostsUnavailable { path: path.to_owned(), }); } let mut temporary = TempFile::new(&directory).map_err(|_| GitError::SshKnownHostsUnavailable { path: path.to_owned(), })?; set_private_file(&temporary, path)?; temporary .write_all(contents) .and_then(|()| temporary.as_file().sync_all()) .and_then(|()| temporary.replace(name)) .and_then(|()| directory.open(".").and_then(|file| file.sync_all())) .map_err(|_| GitError::SshKnownHostsUnavailable { path: path.to_owned(), }) } #[cfg(unix)] fn set_private_file(temporary: &TempFile<'_>, path: &Path) -> Result<(), GitError> { use cap_std::fs::{Permissions, PermissionsExt as _}; temporary .as_file() .set_permissions(Permissions::from_mode(0o600)) .map_err(|_| GitError::SshKnownHostsUnavailable { path: path.to_owned(), }) } #[cfg(not(unix))] fn set_private_file(_temporary: &TempFile<'_>, _path: &Path) -> Result<(), GitError> { Ok(()) } #[cfg(unix)] fn set_private_directory(directory: &Path, source: &Path) -> Result<(), GitError> { use std::os::unix::fs::PermissionsExt as _; fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).map_err(|_| { GitError::SshKnownHostsUnavailable { path: source.to_owned(), } }) } #[cfg(not(unix))] fn set_private_directory(_directory: &Path, _source: &Path) -> Result<(), GitError> { Ok(()) } #[cfg(test)] mod tests { use std::{ fs, sync::{ Arc, atomic::{AtomicUsize, Ordering}, mpsc, }, thread, time::Duration, }; use russh::{ keys::{ HashAlg, PrivateKey, PublicKey, agent::client::AgentClient, ssh_key::{Algorithm, LineEnding}, }, server, }; #[test] fn openssh_host_alias_resolves_connection_and_identity() { let temporary = tempfile::tempdir().expect("temporary directory"); let config = temporary.path().join("config"); fs::write( &config, "Host *\n IdentityFile ~/.ssh/id_ed25519\nHost git.example\n HostName internal.example\n User git\n Port 2222\n", ) .expect("SSH config"); let mut connection = super::SshConnection { host: "git.example".to_owned(), port: 22, user: None, identity: crate::config::SshIdentitySource::KeyFile(temporary.path().join("unused")), known_hosts: temporary.path().join("known_hosts"), }; super::apply_openssh_config( &config, "git.example", temporary.path(), true, &mut connection, ) .expect("resolve SSH config"); assert_eq!(connection.host, "internal.example"); assert_eq!(connection.user.as_deref(), Some("git")); assert_eq!(connection.port, 2222); assert_eq!( connection.identity.key_file(), Some(temporary.path().join(".ssh/id_ed25519").as_path()) ); } use crate::{ config::{GitRemote, RemoteEndpoint, SshFingerprint, SshRemoteAuthentication}, git::{GitError, GitOperationControl, SshPassphraseProvider}, repository::SecretBytes, }; use super::{ GitService, SshSession, client_config, git_service_command, persist_confirmed_host, ssh_host_key, }; struct Passphrase(Option<&'static [u8]>); impl SshPassphraseProvider for Passphrase { fn ssh_key_passphrase( &self, fingerprint: &SshFingerprint, ) -> Result { self.0 .map(|value| SecretBytes::new(value.to_vec())) .ok_or_else(|| GitError::SshKeyPassphraseUnavailable { fingerprint: fingerprint.clone(), }) } } #[derive(Clone)] struct TestServer { user: &'static str, key: PublicKey, attempts: Arc, } impl server::Handler for TestServer { type Error = russh::Error; async fn auth_publickey( &mut self, user: &str, public_key: &PublicKey, ) -> Result { self.attempts.fetch_add(1, Ordering::Relaxed); if user == self.user && public_key == &self.key { Ok(server::Auth::Accept) } else { Ok(server::Auth::reject()) } } } fn key(algorithm: Algorithm) -> PrivateKey { PrivateKey::random(&mut russh::keys::key::safe_rng(), algorithm).expect("generate key") } fn write_key(path: &std::path::Path, key: &PrivateKey) { fs::write( path, key.to_openssh(LineEnding::LF) .expect("encode private key") .as_bytes(), ) .expect("write identity"); } fn start_server( host_key: PrivateKey, user_key: PublicKey, connections: usize, ) -> (u16, Arc, thread::JoinHandle<()>) { let attempts = Arc::new(AtomicUsize::new(0)); let handler = TestServer { user: "git", key: user_key, attempts: attempts.clone(), }; let (port_tx, port_rx) = mpsc::channel(); let join = thread::spawn(move || { let runtime = tokio::runtime::Runtime::new().expect("server runtime"); runtime.block_on(async move { let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) .await .expect("bind server"); port_tx .send(listener.local_addr().expect("server address").port()) .expect("send port"); let config = Arc::new(server::Config { keys: vec![host_key], auth_rejection_time: Duration::ZERO, auth_rejection_time_initial: Some(Duration::ZERO), ..server::Config::default() }); for _ in 0..connections { let (stream, _) = listener.accept().await.expect("accept client"); if let Ok(session) = server::run_stream(config.clone(), stream, handler.clone()).await { let _ = session.await; } } }); }); ( port_rx .recv_timeout(Duration::from_secs(5)) .expect("server port"), attempts, join, ) } fn remote(port: u16, identity: &std::path::Path, known_hosts: &std::path::Path) -> GitRemote { GitRemote::ssh_with_authentication( "origin", format!("ssh://git@127.0.0.1:{port}/team/store.git"), SshRemoteAuthentication::key_file(identity.to_owned(), known_hosts.to_owned()) .expect("SSH authentication"), ) .expect("SSH remote") } #[cfg(unix)] fn start_agent( socket: &std::path::Path, ) -> (tokio::sync::oneshot::Sender<()>, thread::JoinHandle<()>) { let socket = socket.to_owned(); let (ready_tx, ready_rx) = mpsc::channel(); let (stop_tx, stop_rx) = tokio::sync::oneshot::channel(); let join = thread::spawn(move || { let runtime = tokio::runtime::Runtime::new().expect("agent runtime"); runtime.block_on(async move { let listener = tokio::net::UnixListener::bind(socket).expect("bind agent"); ready_tx.send(()).expect("agent ready"); let incoming = tokio_stream::wrappers::UnixListenerStream::new(listener); tokio::select! { result = russh::keys::agent::server::serve(incoming, ()) => { result.expect("serve agent"); } _ = stop_rx => {} } }); }); ready_rx .recv_timeout(Duration::from_secs(5)) .expect("agent ready"); (stop_tx, join) } #[test] fn key_authentication_supports_ed25519_ecdsa_and_rsa() { for algorithm in [ Algorithm::Ed25519, Algorithm::Ecdsa { curve: russh::keys::ssh_key::EcdsaCurve::NistP256, }, Algorithm::Ecdsa { curve: russh::keys::ssh_key::EcdsaCurve::NistP384, }, Algorithm::Ecdsa { curve: russh::keys::ssh_key::EcdsaCurve::NistP521, }, Algorithm::Rsa { hash: Some(russh::keys::HashAlg::Sha512), }, ] { let temporary = tempfile::tempdir().expect("temporary directory"); let identity = key(algorithm.clone()); let host_key = key(algorithm); let identity_path = temporary.path().join("identity"); let known_hosts = temporary.path().join("known_hosts"); write_key(&identity_path, &identity); let (port, attempts, server) = start_server(host_key.clone(), identity.public_key().clone(), 1); persist_confirmed_host( &known_hosts, &ssh_host_key("127.0.0.1", port, host_key.public_key()).expect("host key"), ) .expect("trust host"); let session = SshSession::connect( &remote(port, &identity_path, &known_hosts), &Passphrase(None), &GitOperationControl::default(), ) .expect("authenticate"); session.close().expect("close session"); server.join().expect("join server"); assert_eq!(attempts.load(Ordering::Relaxed), 1); } } #[test] fn client_algorithm_policy_excludes_legacy_ssh_primitives() { let config = client_config(); assert!( config .preferred .kex .iter() .all(|name| !name.as_ref().contains("sha1")) ); assert!(config.preferred.host_key_certificates.is_empty()); assert!(config.preferred.key.iter().all(|algorithm| matches!( algorithm, Algorithm::Ed25519 | Algorithm::Ecdsa { .. } | Algorithm::Rsa { hash: Some(HashAlg::Sha256 | HashAlg::Sha512) } ))); assert!(config.preferred.cipher.iter().all(|name| { let name = name.as_ref(); !name.contains("cbc") && name != "none" })); assert!( config .preferred .mac .iter() .all(|name| !name.as_ref().contains("sha1")) ); assert_eq!( config .preferred .compression .iter() .map(AsRef::as_ref) .collect::>(), ["none"] ); } #[test] fn encrypted_key_requires_the_matching_protected_passphrase() { let temporary = tempfile::tempdir().expect("temporary directory"); let identity = key(Algorithm::Ed25519); let encrypted = identity .encrypt(&mut russh::keys::key::safe_rng(), b"correct") .expect("encrypt key"); let host_key = key(Algorithm::Ed25519); let identity_path = temporary.path().join("identity"); let known_hosts = temporary.path().join("known_hosts"); write_key(&identity_path, &encrypted); let (port, attempts, server) = start_server(host_key.clone(), identity.public_key().clone(), 2); persist_confirmed_host( &known_hosts, &ssh_host_key("127.0.0.1", port, host_key.public_key()).expect("host key"), ) .expect("trust host"); let remote = remote(port, &identity_path, &known_hosts); let error = SshSession::connect( &remote, &Passphrase(Some(b"wrong")), &GitOperationControl::default(), ) .expect_err("reject wrong passphrase"); assert!(matches!(error, GitError::SshKeyPassphraseRejected { .. })); let session = SshSession::connect( &remote, &Passphrase(Some(b"correct")), &GitOperationControl::default(), ) .expect("accept protected passphrase"); session.close().expect("close session"); server.join().expect("join server"); assert_eq!(attempts.load(Ordering::Relaxed), 1); } #[test] fn unknown_host_is_confirmed_explicitly_before_authentication() { let temporary = tempfile::tempdir().expect("temporary directory"); let identity = key(Algorithm::Ed25519); let host_key = key(Algorithm::Ed25519); let identity_path = temporary.path().join("identity"); let known_hosts = temporary.path().join("known_hosts"); write_key(&identity_path, &identity); let (port, attempts, server) = start_server(host_key, identity.public_key().clone(), 2); let remote = remote(port, &identity_path, &known_hosts); let error = SshSession::connect(&remote, &Passphrase(None), &GitOperationControl::default()) .expect_err("unknown host"); let GitError::UnknownSshHostKey { host_key } = error else { panic!("unexpected error: {error:?}"); }; assert_eq!(attempts.load(Ordering::Relaxed), 0); persist_confirmed_host(&known_hosts, &host_key).expect("persist confirmation"); let session = SshSession::connect(&remote, &Passphrase(None), &GitOperationControl::default()) .expect("authenticate trusted host"); session.close().expect("close session"); server.join().expect("join server"); assert_eq!(attempts.load(Ordering::Relaxed), 1); } #[test] fn changed_host_fails_before_authentication_and_is_not_replaced() { let temporary = tempfile::tempdir().expect("temporary directory"); let identity = key(Algorithm::Ed25519); let host_key = key(Algorithm::Ed25519); let old_host_key = key(Algorithm::Ed25519); let identity_path = temporary.path().join("identity"); let known_hosts = temporary.path().join("known_hosts"); write_key(&identity_path, &identity); let (port, attempts, server) = start_server(host_key, identity.public_key().clone(), 1); persist_confirmed_host( &known_hosts, &ssh_host_key("127.0.0.1", port, old_host_key.public_key()).expect("old host key"), ) .expect("trust old host"); let before = fs::read(&known_hosts).expect("known hosts"); let error = SshSession::connect( &remote(port, &identity_path, &known_hosts), &Passphrase(None), &GitOperationControl::default(), ) .expect_err("changed host"); assert!(matches!(error, GitError::ChangedSshHostKey { .. })); assert_eq!(fs::read(&known_hosts).expect("known hosts"), before); assert_eq!(attempts.load(Ordering::Relaxed), 0); server.join().expect("join server"); } #[test] fn hashed_nondefault_host_entry_matches() { use hmac::{Hmac, Mac as _}; use sha1::Sha1; let temporary = tempfile::tempdir().expect("temporary directory"); let host_key = key(Algorithm::Ed25519); let target = "[example.test]:2222"; let salt = b"01234567890123456789"; let mut mac = Hmac::::new_from_slice(salt).expect("HMAC"); mac.update(target.as_bytes()); let hash = mac.finalize().into_bytes(); let line = format!( "|1|{}|{} {}\n", data_encoding::BASE64.encode(salt), data_encoding::BASE64.encode(&hash), host_key.public_key().to_openssh().expect("public key") ); let path = temporary.path().join("known_hosts"); fs::write(&path, line).expect("known hosts"); assert!(matches!( super::verify_known_host(&path, "example.test", 2222, host_key.public_key()) .expect("verify"), super::HostStatus::Trusted )); let typed = ssh_host_key("example.test", 2222, host_key.public_key()).expect("host key"); let rendered = format!("{typed:?}"); assert!(rendered.contains(typed.fingerprint().as_str())); assert!(!rendered.contains(typed.encoded())); fs::write(&path, format!("@revoked {target} {}\n", typed.encoded())).expect("revoked host"); assert!(matches!( super::verify_known_host(&path, "example.test", 2222, host_key.public_key()) .expect("verify revoked"), super::HostStatus::Changed { .. } )); fs::write(&path, format!("{target},!{target} {}\n", typed.encoded())) .expect("negated host"); assert!(matches!( super::verify_known_host(&path, "example.test", 2222, host_key.public_key()) .expect("verify negated"), super::HostStatus::Unknown )); } #[test] fn cancellation_does_not_touch_known_hosts() { let temporary = tempfile::tempdir().expect("temporary directory"); let identity = key(Algorithm::Ed25519); let identity_path = temporary.path().join("identity"); let known_hosts = temporary.path().join("known_hosts"); write_key(&identity_path, &identity); fs::write(&known_hosts, b"unchanged\n").expect("known hosts"); let control = GitOperationControl::default(); control.cancel(); assert_eq!( SshSession::connect( &remote(9, &identity_path, &known_hosts), &Passphrase(None), &control, ) .expect_err("cancelled"), GitError::Cancelled ); assert_eq!(fs::read(&known_hosts).expect("known hosts"), b"unchanged\n"); } #[test] fn dns_and_network_failures_are_distinct_from_ssh_protocol_failure() { let temporary = tempfile::tempdir().expect("temporary directory"); let identity = key(Algorithm::Ed25519); let identity_path = temporary.path().join("identity"); let known_hosts = temporary.path().join("known_hosts"); write_key(&identity_path, &identity); fs::write(&known_hosts, b"").expect("known hosts"); let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("unused port"); let port = listener.local_addr().expect("address").port(); drop(listener); assert_eq!( SshSession::connect( &remote(port, &identity_path, &known_hosts), &Passphrase(None), &GitOperationControl::default(), ) .expect_err("unavailable network"), GitError::NetworkUnavailable ); let dns_remote = GitRemote::ssh_with_authentication( "origin", "ssh://git@does-not-exist.invalid/team/store.git", SshRemoteAuthentication::key_file(identity_path, known_hosts) .expect("SSH authentication"), ) .expect("SSH remote"); assert_eq!( SshSession::connect( &dns_remote, &Passphrase(None), &GitOperationControl::default(), ) .expect_err("unavailable DNS name"), GitError::NetworkUnavailable ); } #[test] fn upload_pack_command_preserves_path_semantics_without_injection() { for (url, expected) in [ ( "ssh://git@example.test/team/repo%20with%20%27quote%27%3B%24%28touch%20x%29.git", "/team/repo with 'quote';$(touch x).git", ), ( "git@example.test:team/repo with 'quote';$(touch x).git", "team/repo with 'quote';$(touch x).git", ), ("ssh://git@example.test/~alice/repo.git", "~alice/repo.git"), ( "git@example.test:GIT_PROTOCOL=version=2/repo.git", "GIT_PROTOCOL=version=2/repo.git", ), ] { let endpoint = RemoteEndpoint::parse(url).expect("valid SSH endpoint"); for (service, expected_service) in [ (GitService::UploadPack, "git-upload-pack"), (GitService::ReceivePack, "git-receive-pack"), ] { let command = git_service_command(service, endpoint.as_ssh().expect("SSH").path()) .expect("safe command"); let command = std::str::from_utf8(&command).expect("UTF-8 command"); assert_eq!( shlex::split(command).expect("shell command"), [expected_service, expected] ); } } assert!(RemoteEndpoint::parse("git@example.test:-upload-pack").is_err()); assert!(RemoteEndpoint::parse("git@example.test:repo\nsecond").is_err()); } #[cfg(unix)] #[test] fn agent_authentication_selects_one_configured_fingerprint() { let temporary = tempfile::tempdir().expect("temporary directory"); let socket = temporary.path().join("agent.sock"); let known_hosts = temporary.path().join("known_hosts"); let identity = key(Algorithm::Ed25519); let host_key = key(Algorithm::Ed25519); let (stop_agent, agent_thread) = start_agent(&socket); tokio::runtime::Runtime::new() .expect("client runtime") .block_on(async { let mut agent = AgentClient::connect_uds(&socket) .await .expect("connect agent"); agent .add_identity(&identity, &[]) .await .expect("add identity"); }); let selected = super::fingerprint(identity.public_key()).expect("fingerprint"); let missing = super::fingerprint(key(Algorithm::Ed25519).public_key()).expect("missing fingerprint"); let (port, attempts, server) = start_server(host_key.clone(), identity.public_key().clone(), 2); persist_confirmed_host( &known_hosts, &ssh_host_key("127.0.0.1", port, host_key.public_key()).expect("host key"), ) .expect("trust host"); let remote_for = |fingerprint| { GitRemote::ssh_with_authentication( "origin", format!("ssh://git@127.0.0.1:{port}/team/store.git"), SshRemoteAuthentication::agent( fingerprint, Some(socket.clone()), known_hosts.clone(), ) .expect("agent authentication"), ) .expect("SSH remote") }; assert!(matches!( SshSession::connect( &remote_for(missing.clone()), &Passphrase(None), &GitOperationControl::default(), ) .expect_err("do not spray another agent key"), GitError::SshAgentIdentityMissing { fingerprint } if fingerprint == missing )); let session = SshSession::connect( &remote_for(selected), &Passphrase(None), &GitOperationControl::default(), ) .expect("authenticate with agent"); session.close().expect("close session"); server.join().expect("join server"); assert_eq!(attempts.load(Ordering::Relaxed), 1); let _ = stop_agent.send(()); agent_thread.join().expect("join agent"); } #[cfg(unix)] #[test] fn unavailable_agent_missing_key_and_rejected_identities_are_typed_failures() { let temporary = tempfile::tempdir().expect("temporary directory"); let identity = key(Algorithm::Ed25519); let host_key = key(Algorithm::Ed25519); let identity_path = temporary.path().join("identity"); let known_hosts = temporary.path().join("known_hosts"); write_key(&identity_path, &identity); let (port, attempts, server) = start_server(host_key.clone(), identity.public_key().clone(), 4); persist_confirmed_host( &known_hosts, &ssh_host_key("127.0.0.1", port, host_key.public_key()).expect("host key"), ) .expect("trust host"); let agent_remote = GitRemote::ssh_with_authentication( "origin", format!("ssh://git@127.0.0.1:{port}/team/store.git"), SshRemoteAuthentication::agent( super::fingerprint(identity.public_key()).expect("fingerprint"), Some(temporary.path().join("missing-agent.sock")), known_hosts.clone(), ) .expect("agent authentication"), ) .expect("SSH remote"); assert_eq!( SshSession::connect( &agent_remote, &Passphrase(None), &GitOperationControl::default(), ) .expect_err("missing agent"), GitError::SshAgentUnavailable ); let wrong_user = GitRemote::ssh_with_authentication( "origin", format!("ssh://wrong@127.0.0.1:{port}/team/store.git"), SshRemoteAuthentication::key_file(identity_path.clone(), known_hosts.clone()) .expect("key authentication"), ) .expect("SSH remote"); assert_eq!( SshSession::connect( &wrong_user, &Passphrase(None), &GitOperationControl::default(), ) .expect_err("wrong user"), GitError::SshAuthenticationRejected ); let wrong_identity_path = temporary.path().join("wrong-identity"); write_key(&wrong_identity_path, &key(Algorithm::Ed25519)); assert_eq!( SshSession::connect( &remote(port, &wrong_identity_path, &known_hosts), &Passphrase(None), &GitOperationControl::default(), ) .expect_err("wrong key"), GitError::SshAuthenticationRejected ); let missing_identity = temporary.path().join("missing-identity"); assert_eq!( SshSession::connect( &remote(port, &missing_identity, &known_hosts), &Passphrase(None), &GitOperationControl::default(), ) .expect_err("missing key"), GitError::SshIdentityMissing { path: missing_identity } ); server.join().expect("join server"); assert_eq!(attempts.load(Ordering::Relaxed), 2); } }