Complete SSH transport release audit (#118)
Some checks failed
Dependency security audit / rustsec (push) Has been cancelled

This commit is contained in:
2026-08-25 22:07:47 +02:00
parent a737e74aae
commit a3da9fda69
18 changed files with 489 additions and 212 deletions

View File

@@ -14,11 +14,12 @@ use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use hmac::{Hmac, Mac as _};
use russh::{
ChannelMsg, Disconnect, client,
ChannelMsg, Disconnect, Preferred, cipher, client, compression, kex,
keys::{
HashAlg, PrivateKey, PublicKey, agent::client::AgentClient, key::PrivateKeyWithHashAlg,
ssh_key::Algorithm,
},
mac,
};
use sha1::Sha1;
@@ -140,19 +141,7 @@ impl SshSession {
port,
known_hosts,
};
let mut config = client::Config {
inactivity_timeout: Some(CONNECTION_TIMEOUT),
..client::Config::default()
};
config.preferred.key = Cow::Owned(
config
.preferred
.key
.iter()
.filter(|algorithm| !matches!(algorithm, Algorithm::Rsa { hash: None }))
.cloned()
.collect(),
);
let config = client_config();
let handle = runtime.block_on(async {
controlled(
tokio::time::timeout(
@@ -300,6 +289,61 @@ impl SshSession {
}
}
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>,
@@ -983,7 +1027,7 @@ mod tests {
use russh::{
keys::{
PrivateKey, PublicKey,
HashAlg, PrivateKey, PublicKey,
agent::client::AgentClient,
ssh_key::{Algorithm, LineEnding},
},
@@ -997,7 +1041,8 @@ mod tests {
};
use super::{
GitService, SshSession, git_service_command, persist_confirmed_host, ssh_host_key,
GitService, SshSession, client_config, git_service_command, persist_confirmed_host,
ssh_host_key,
};
struct Passphrase(Option<&'static [u8]>);
@@ -1143,13 +1188,19 @@ mod tests {
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);
let host_key = key(Algorithm::Ed25519);
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);
@@ -1172,6 +1223,47 @@ mod tests {
}
}
#[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");
@@ -1331,7 +1423,7 @@ mod tests {
}
#[test]
fn unavailable_network_is_distinct_from_ssh_protocol_failure() {
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");
@@ -1350,6 +1442,23 @@ mod tests {
.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]