Files
IronStorage/crates/storage/src/ssh.rs
Georg Bauer 300ccf5f1f
Some checks failed
Dependency security audit / rustsec (push) Has been cancelled
Use repository Git remotes and SSH config
2026-08-26 09:32:21 +02:00

1822 lines
63 KiB
Rust

//! 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<SshStdout, SshStdin>;
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<Result<(), GitError>>;
pub(crate) struct SshStdout {
receiver: tokio::sync::mpsc::Receiver<Vec<u8>>,
current: Vec<u8>,
offset: usize,
}
impl Read for SshStdout {
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
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<Vec<u8>>,
}
impl Write for SshStdin {
fn write(&mut self, input: &[u8]) -> io::Result<usize> {
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<HostVerifier>,
}
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<Self, GitError> {
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<SshGitCommand, GitError> {
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<u16>)>,
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<SshRawGitCommand, GitError> {
self.open_git_command(remote, GitService::ReceivePack, control)
}
fn open_git_command(
&self,
remote: &GitRemote,
service: GitService,
control: &GitOperationControl,
) -> Result<SshRawGitCommand, GitError> {
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<String>,
identity: SshIdentitySource,
pub(crate) known_hosts: PathBuf,
}
pub(crate) fn resolve_connection(
endpoint: &SshEndpoint,
authentication: Option<&SshRemoteAuthentication>,
) -> Result<SshConnection, GitError> {
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<PathBuf> {
#[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<HostVerifier>,
command: Vec<u8>,
control: &GitOperationControl,
) -> Result<russh::Channel<client::Msg>, 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<client::Msg>,
mut input: tokio::sync::mpsc::Receiver<Vec<u8>>,
output: tokio::sync::mpsc::Sender<Vec<u8>>,
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<Vec<u8>, 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<u8>, 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::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.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<russh::Error> 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<bool, Self::Error> {
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<HostVerifier>,
user: &str,
path: &Path,
passphrases: &dyn SshPassphraseProvider,
control: &GitOperationControl,
) -> Result<bool, GitError> {
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<HostVerifier>,
user: &str,
selected: &SshFingerprint,
socket: Option<&Path>,
control: &GitOperationControl,
) -> Result<bool, GitError> {
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<AgentClient<Box<dyn russh::keys::agent::client::AgentStream + Send + Unpin>>, 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<AgentClient<Box<dyn russh::keys::agent::client::AgentStream + Send + Unpin>>, 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<HostVerifier>,
key: &PublicKey,
control: &GitOperationControl,
) -> Result<Option<HashAlg>, 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<F, T, E>(
future: F,
control: &GitOperationControl,
) -> Result<Result<T, E>, GitError>
where
F: Future<Output = Result<T, E>>,
{
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, GitError> {
SshFingerprint::parse(key.fingerprint(HashAlg::Sha256).to_string())
.map_err(|_| GitError::SshProtocolFailed)
}
fn ssh_host_key(host: &str, port: u16, key: &PublicKey) -> Result<SshHostKey, GitError> {
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<HostStatus, GitError> {
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::<Sha1>::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<Vec<u8>, 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<Vec<u8>> {
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<SecretBytes, GitError> {
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<AtomicUsize>,
}
impl server::Handler for TestServer {
type Error = russh::Error;
async fn auth_publickey(
&mut self,
user: &str,
public_key: &PublicKey,
) -> Result<server::Auth, Self::Error> {
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<AtomicUsize>, 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::<Vec<_>>(),
["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::<Sha1>::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);
}
}