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

@@ -545,66 +545,69 @@ fn assert_pack_request(request: &[u8], old: Option<&str>, new: &str) {
#[test]
fn new_branch_fast_forward_and_already_current_push_are_confirmed() {
for (old_kind, behavior) in [
(None, ReceiveBehavior::Success),
(Some("base"), ReceiveBehavior::Success),
] {
for scp_like in [false, true] {
for old_kind in [None, Some("base")] {
let temporary = tempfile::tempdir().expect("temporary directory");
let local = local_repository(&temporary.path().join("local"));
let base = commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let new = commit(&local, "entry.gpg", b"ciphertext", "Add entry");
let old = old_kind.map(|_| base.clone());
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
None,
old.clone(),
ReceiveBehavior::Success,
false,
1,
);
let remote = remote(temporary.path(), &server, &user_key, scp_like);
let mut local = local;
local.add_remote("origin", remote.url()).expect("remote");
let outcome = local
.push(&remote, Some("main"), &Credentials)
.expect("push");
assert_eq!(outcome.new_id(), new);
assert_eq!(tracking_id(&local), Some(new.clone()));
server.join.join().expect("server");
let observed = server.observed.lock().expect("observed");
let expected = if scp_like {
b"git-receive-pack 'team/store.git'".to_vec()
} else {
b"git-receive-pack '/team/store.git'".to_vec()
};
assert_eq!(observed.commands, [expected]);
assert_eq!(observed.receive_requests.len(), 1);
assert_pack_request(&observed.receive_requests[0], old.as_deref(), &new);
}
}
for scp_like in [false, true] {
let temporary = tempfile::tempdir().expect("temporary directory");
let local = local_repository(&temporary.path().join("local"));
let base = commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let new = commit(&local, "entry.gpg", b"ciphertext", "Add entry");
let old = old_kind.map(|_| base.clone());
let head = commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
None,
old.clone(),
behavior,
Some(head.clone()),
ReceiveBehavior::Success,
false,
1,
);
let remote = remote(temporary.path(), &server, &user_key, false);
let remote = remote(temporary.path(), &server, &user_key, scp_like);
let mut local = local;
local.add_remote("origin", remote.url()).expect("remote");
let outcome = local
local
.push(&remote, Some("main"), &Credentials)
.expect("push");
assert_eq!(outcome.new_id(), new);
assert_eq!(tracking_id(&local), Some(new.clone()));
.expect("current push");
assert_eq!(tracking_id(&local), Some(head));
server.join.join().expect("server");
let observed = server.observed.lock().expect("observed");
assert_eq!(
observed.commands,
[b"git-receive-pack '/team/store.git'".to_vec()]
server.observed.lock().expect("observed").receive_requests,
[b"0000".to_vec()]
);
assert_eq!(observed.receive_requests.len(), 1);
assert_pack_request(&observed.receive_requests[0], old.as_deref(), &new);
}
let temporary = tempfile::tempdir().expect("temporary directory");
let local = local_repository(&temporary.path().join("local"));
let head = commit(&local, ".gpg-id", b"ALICE\n", "Initialize");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
None,
Some(head.clone()),
ReceiveBehavior::Success,
false,
1,
);
let remote = remote(temporary.path(), &server, &user_key, false);
let mut local = local;
local.add_remote("origin", remote.url()).expect("remote");
local
.push(&remote, Some("main"), &Credentials)
.expect("current push");
assert_eq!(tracking_id(&local), Some(head));
server.join.join().expect("server");
assert_eq!(
server.observed.lock().expect("observed").receive_requests,
[b"0000".to_vec()]
);
}
#[test]

View File

@@ -377,155 +377,111 @@ fn remote_with_url(root: &Path, server: &Server, identity: &PrivateKey, url: Str
}
#[test]
fn upload_pack_drives_discovery_clone_fetch_fast_forward_merge_and_conflict() {
let temporary = tempfile::tempdir().expect("temporary directory");
let source_root = temporary.path().join("source");
fs::create_dir(&source_root).expect("source directory");
let source_store = Repository::open(&source_root).expect("source store");
let source = GitRepository::init(&source_store, identity()).expect("source Git");
commit(&source, ".gpg-id", b"ALICE\n", "Initialize recipients");
commit(&source, "shared.gpg", b"base", "Add shared entry");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
populated_fixture(&source),
Behavior::Normal,
5,
);
let remote = remote(temporary.path(), &server, &user_key, "/team/store.git");
fn both_ssh_url_forms_drive_discovery_clone_fetch_fast_forward_merge_and_conflict() {
for scp_like in [false, true] {
let temporary = tempfile::tempdir().expect("temporary directory");
let source_root = temporary.path().join("source");
fs::create_dir(&source_root).expect("source directory");
let source_store = Repository::open(&source_root).expect("source store");
let source = GitRepository::init(&source_store, identity()).expect("source Git");
commit(&source, ".gpg-id", b"ALICE\n", "Initialize recipients");
commit(&source, "shared.gpg", b"base", "Add shared entry");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
populated_fixture(&source),
Behavior::Normal,
6,
);
let remote = if scp_like {
remote_with_url(
temporary.path(),
&server,
&user_key,
"git@127.0.0.1:team/store.git".to_owned(),
)
} else {
remote(temporary.path(), &server, &user_key, "/team/store.git")
};
let branches = GitRepository::discover_remote_branches(
temporary.path(),
identity(),
&remote,
&Credentials,
&GitOperationControl::default(),
)
.expect("discover branches");
assert_eq!(branches, ["feature", "main"]);
let destination = temporary.path().join("clone");
let clone = GitRepository::clone_into(&destination, identity(), &remote, &Credentials)
.expect("clone over SSH");
assert_eq!(
fs::read(destination.join("shared.gpg")).expect("entry"),
b"base"
);
assert!(
clone
.repository
.try_find_reference("refs/tags/v1")
.expect("tag lookup")
.is_some()
);
commit(&source, "remote-one.gpg", b"remote", "Remote fast-forward");
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
assert_eq!(
clone
.pull(&remote, Some("main"), &Credentials)
.expect("fast-forward pull"),
PullOutcome::FastForward
);
commit(&clone, "local.gpg", b"local", "Local change");
commit(
&source,
"remote-two.gpg",
b"remote",
"Remote parallel change",
);
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
assert_eq!(
clone
.pull(&remote, Some("main"), &Credentials)
.expect("merge pull"),
PullOutcome::Merged
);
commit(&clone, "shared.gpg", b"local conflict", "Local conflict");
commit(&source, "shared.gpg", b"remote conflict", "Remote conflict");
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
let before = fs::read(destination.join("shared.gpg")).expect("local entry");
assert!(matches!(
clone.pull(&remote, Some("main"), &Credentials),
Err(GitError::MergeConflicts { .. })
));
assert_eq!(
fs::read(destination.join("shared.gpg")).expect("entry"),
before
);
server.join.join().expect("server");
let commands = server.commands.lock().expect("commands");
assert_eq!(commands.len(), 5);
assert!(
commands
.iter()
.all(|command| command == b"git-upload-pack '/team/store.git'")
);
}
#[test]
fn scp_remote_drives_discovery_clone_fetch_and_pull() {
let temporary = tempfile::tempdir().expect("temporary directory");
let source_root = temporary.path().join("source");
fs::create_dir(&source_root).expect("source directory");
let source_store = Repository::open(&source_root).expect("source store");
let source = GitRepository::init(&source_store, identity()).expect("source Git");
commit(&source, ".gpg-id", b"ALICE\n", "Initialize recipients");
let user_key = key();
let server = start_server(
user_key.public_key().clone(),
populated_fixture(&source),
Behavior::Normal,
4,
);
let remote = remote_with_url(
temporary.path(),
&server,
&user_key,
"git@127.0.0.1:team/store.git".to_owned(),
);
assert_eq!(
GitRepository::discover_remote_branches(
let branches = GitRepository::discover_remote_branches(
temporary.path(),
identity(),
&remote,
&Credentials,
&GitOperationControl::default(),
)
.expect("discover branches"),
["feature", "main"]
);
let destination = temporary.path().join("clone");
let clone = GitRepository::clone_into(&destination, identity(), &remote, &Credentials)
.expect("clone over scp-like SSH remote");
commit(&source, "remote.gpg", b"remote", "Remote fast-forward");
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
assert!(
clone
.fetch(&remote, &Credentials)
.expect("fetch over scp-like SSH remote")
.received_pack()
);
assert_eq!(
clone
.pull(&remote, Some("main"), &Credentials)
.expect("pull over scp-like SSH remote"),
PullOutcome::FastForward
);
.expect("discover branches");
assert_eq!(branches, ["feature", "main"]);
server.join.join().expect("server");
assert!(
server
.commands
.lock()
.expect("commands")
.iter()
.all(|command| command == b"git-upload-pack 'team/store.git'")
);
let destination = temporary.path().join("clone");
let clone = GitRepository::clone_into(&destination, identity(), &remote, &Credentials)
.expect("clone over SSH");
assert_eq!(
fs::read(destination.join("shared.gpg")).expect("entry"),
b"base"
);
assert!(
clone
.repository
.try_find_reference("refs/tags/v1")
.expect("tag lookup")
.is_some()
);
commit(&source, "remote-one.gpg", b"remote", "Remote fast-forward");
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
assert!(
clone
.fetch(&remote, &Credentials)
.expect("fetch over SSH")
.received_pack()
);
assert_eq!(
clone
.pull(&remote, Some("main"), &Credentials)
.expect("fast-forward pull"),
PullOutcome::FastForward
);
commit(&clone, "local.gpg", b"local", "Local change");
commit(
&source,
"remote-two.gpg",
b"remote",
"Remote parallel change",
);
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
assert_eq!(
clone
.pull(&remote, Some("main"), &Credentials)
.expect("merge pull"),
PullOutcome::Merged
);
commit(&clone, "shared.gpg", b"local conflict", "Local conflict");
commit(&source, "shared.gpg", b"remote conflict", "Remote conflict");
*server.fixture.lock().expect("fixture") = populated_fixture(&source);
let before = fs::read(destination.join("shared.gpg")).expect("local entry");
assert!(matches!(
clone.pull(&remote, Some("main"), &Credentials),
Err(GitError::MergeConflicts { .. })
));
assert_eq!(
fs::read(destination.join("shared.gpg")).expect("entry"),
before
);
server.join.join().expect("server");
let commands = server.commands.lock().expect("commands");
assert_eq!(commands.len(), 6);
let expected = if scp_like {
b"git-upload-pack 'team/store.git'".as_slice()
} else {
b"git-upload-pack '/team/store.git'".as_slice()
};
assert!(commands.iter().all(|command| command == expected));
}
}
#[test]

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]

View File

@@ -208,12 +208,17 @@ fn ssh_remotes_are_typed_but_unavailable_before_transport_or_mutation() -> TestR
#[cfg(feature = "ssh")]
#[test]
fn ssh_feature_allows_repository_remote_configuration() -> TestResult {
fn ssh_feature_allows_both_remote_forms_through_add_set_and_get() -> TestResult {
let temporary = tempfile::tempdir()?;
let store = Repository::open(temporary.path())?;
let mut git = GitRepository::init(&store, identity())?;
git.add_remote("origin", "git@example.test:team/store.git")?;
assert_eq!(git.remote_url("origin")?, "git@example.test:team/store.git");
git.set_remote_url("origin", "ssh://git@example.test/team/store.git")?;
assert_eq!(
git.remote_url("origin")?,
"ssh://git@example.test/team/store.git"
);
Ok(())
}

View File

@@ -0,0 +1,53 @@
const WORKSPACE: &str = include_str!("../../../Cargo.toml");
const STORAGE_MANIFEST: &str = include_str!("../Cargo.toml");
const APPLE_MANIFEST: &str = include_str!("../../apple/Cargo.toml");
const CLI_MANIFEST: &str = include_str!("../../../apps/cli/Cargo.toml");
const TUI_MANIFEST: &str = include_str!("../../../apps/tui/Cargo.toml");
const DESKTOP_MANIFEST: &str = include_str!("../../../apps/desktop/Cargo.toml");
const SSH_SOURCE: &str = include_str!("../src/ssh.rs");
const GIT_SOURCE: &str = include_str!("../src/git.rs");
const AUDIT: &str = include_str!("../../../docs/ssh-transport-audit.md");
const AUDIT_WORKFLOW: &str = include_str!("../../../.gitea/workflows/security-audit.yml");
#[test]
fn release_feature_and_advisory_boundaries_stay_explicit() {
assert!(WORKSPACE.contains(
"russh = { version = \"=0.63.1\", default-features = false, features = [\"ring\", \"rsa\"] }"
));
assert!(STORAGE_MANIFEST.contains("ssh = [\"dep:russh\", \"dep:tokio\"]"));
for manifest in [CLI_MANIFEST, TUI_MANIFEST, DESKTOP_MANIFEST] {
assert!(manifest.contains("features = [\"ssh\"]"));
}
assert!(APPLE_MANIFEST.contains("default-features = false, features = [\"full\"]"));
assert!(!APPLE_MANIFEST.contains("features = [\"ssh\"]"));
assert!(AUDIT_WORKFLOW.contains("cargo-audit --locked --version 0.22.2"));
assert!(AUDIT_WORKFLOW.contains("cargo audit --ignore RUSTSEC-2023-0071"));
assert!(AUDIT.contains("RUSTSEC-2023-0071"));
}
#[test]
fn production_ssh_transport_has_no_process_unsafe_or_proxy_escape_hatch() {
let process_command = ["process", "::Command"].concat();
let command_constructor = ["Command", "::new("].concat();
let unsafe_block = ["unsafe", " {"].concat();
for (name, source) in [("ssh.rs", SSH_SOURCE), ("git.rs", GIT_SOURCE)] {
let production = source.split("#[cfg(test)]").next().unwrap_or(source);
for forbidden in [
process_command.as_str(),
command_constructor.as_str(),
unsafe_block.as_str(),
"ProxyCommand",
"proxy_command",
"russh_config",
] {
assert!(
!production.contains(forbidden),
"{name} contains forbidden production token {forbidden}"
);
}
}
assert!(SSH_SOURCE.contains("check_server_key"));
assert!(SSH_SOURCE.contains("verify_known_host"));
assert!(SSH_SOURCE.contains("MAX_SSH_DIAGNOSTIC_BYTES"));
assert!(SSH_SOURCE.contains("MAX_CHANNEL_CHUNK"));
}