1822 lines
63 KiB
Rust
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);
|
|
}
|
|
}
|