michal/tit
Browse tree · Show commit · Download archive
Blob: tests/ssh.rs
#[allow(dead_code, reason = "the SSH test uses only the shared key boundary")]
#[path = "../src/auth.rs"]
mod auth;
#[allow(dead_code, reason = "the SSH identity test does not use domain models")]
#[path = "../src/domain/mod.rs"]
mod domain;
#[allow(
dead_code,
reason = "the SSH identity test does not use each Git service API"
)]
#[path = "../src/git/mod.rs"]
mod git;
#[allow(
dead_code,
reason = "the SSH identity test does not use issue commands"
)]
#[path = "../src/issue.rs"]
mod issue;
#[allow(dead_code, reason = "the SSH identity test does not run maintenance")]
#[path = "../src/maintenance.rs"]
mod maintenance;
#[allow(
dead_code,
reason = "the SSH identity test does not use repository policy"
)]
#[path = "../src/policy.rs"]
mod policy;
#[allow(dead_code, reason = "the SSH identity test does not use pull requests")]
#[path = "../src/pull_request.rs"]
mod pull_request;
#[path = "../src/rate_limit.rs"]
mod rate_limit;
#[allow(
dead_code,
reason = "the SSH identity test does not create repositories"
)]
#[path = "../src/repository.rs"]
mod repository;
#[allow(
dead_code,
reason = "the SSH identity test does not start a Git service"
)]
#[path = "../src/ssh.rs"]
mod ssh;
#[allow(
dead_code,
reason = "the SSH identity test does not use the intent store"
)]
#[path = "../src/store/mod.rs"]
mod store;
#[path = "../src/telemetry.rs"]
mod telemetry;
use std::fs;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use auth::SshPublicKey;
use ssh::RunningSshServer;
use tempfile::TempDir;
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn stock_openssh_authenticates_supported_keys_and_ignores_the_username() {
for fixture in [KeyFixture::Ed25519, KeyFixture::EcdsaP256] {
let directory = TempDir::new().expect("create a key directory");
let private_key = directory.path().join(fixture.name());
generate_key(&private_key, fixture);
let key = parse_public_key(&private_key);
let server = start(&[key]).await;
for username in ["alice", "the-ssh-user-is-ignored"] {
let output = ssh(&server, &private_key, username, &["tit --version"]);
assert!(
output.status.success(),
"authenticate {}: {}",
fixture.name(),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8(output.stdout).expect("read the version output"),
format!("tit {}\n", env!("CARGO_PKG_VERSION"))
);
}
assert_eq!(server.audit().accepted_exec, 2);
server.shutdown().await.expect("stop the SSH server");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn reports_available_commands_and_explains_invalid_commands() {
let directory = TempDir::new().expect("create a key directory");
let private_key = directory.path().join("ed25519");
generate_key(&private_key, KeyFixture::Ed25519);
let server = start(&[parse_public_key(&private_key)]).await;
let help = ssh(&server, &private_key, "alice", &["help"]);
assert!(
help.status.success(),
"help failed: {}",
String::from_utf8_lossy(&help.stderr)
);
let help_text = String::from_utf8(help.stdout).expect("read the help output");
assert!(help_text.contains("Available tit SSH commands:"));
assert!(help_text.contains("auth ONE-TIME-SECRET"));
assert!(help_text.contains("repo create NAME"));
assert!(!help_text.contains("object-format"));
assert!(help_text.contains("issue list OWNER/REPOSITORY"));
assert!(help_text.contains("issue comment OWNER/REPOSITORY NUMBER"));
assert!(help_text.contains("issue close OWNER/REPOSITORY NUMBER"));
assert!(help_text.contains("issue reopen OWNER/REPOSITORY NUMBER"));
assert!(help_text.contains("pr list OWNER/REPOSITORY"));
assert!(help_text.contains("pr create OWNER/REPOSITORY BASE HEAD"));
assert!(help_text.contains("pr close OWNER/REPOSITORY NUMBER"));
assert!(help_text.contains("pr reopen OWNER/REPOSITORY NUMBER"));
assert!(help_text.contains("pr checkout OWNER/REPOSITORY NUMBER"));
let invalid = ssh(&server, &private_key, "alice", &["not-a-command"]);
assert!(!invalid.status.success());
assert_eq!(
String::from_utf8(invalid.stderr).expect("read the invalid-command error"),
"tit: The command is not valid.\nUse 'help' to list the available commands.\n"
);
let malformed = ssh(&server, &private_key, "alice", &["repo create"]);
assert!(!malformed.status.success());
let malformed_error =
String::from_utf8(malformed.stderr).expect("read the malformed-command error");
assert!(malformed_error.contains("usage: repo create NAME"));
assert!(malformed_error.contains("Use 'help' to list the available commands."));
let invalid_json = ssh(
&server,
&private_key,
"alice",
&["not-a-command --output json"],
);
assert!(!invalid_json.status.success());
let response: serde_json::Value =
serde_json::from_slice(&invalid_json.stdout).expect("parse the JSON error");
assert_eq!(response["version"], 1);
assert_eq!(response["status"], "error");
assert_eq!(response["error"]["code"], "invalid-command");
server.shutdown().await.expect("stop the SSH server");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rejects_unknown_keys_and_forced_rsa_sha1_authentication() {
let directory = TempDir::new().expect("create a key directory");
let authorized_private = directory.path().join("authorized");
let unknown_private = directory.path().join("unknown");
generate_key(&authorized_private, KeyFixture::Ed25519);
generate_key(&unknown_private, KeyFixture::Ed25519);
let authorized_key = parse_public_key(&authorized_private);
let server = start(&[authorized_key]).await;
let unknown = ssh(&server, &unknown_private, "alice", &["tit --version"]);
assert!(!unknown.status.success());
server.shutdown().await.expect("stop the SSH server");
let rsa_private = directory.path().join("rsa");
generate_key(&rsa_private, KeyFixture::Rsa3072);
let rsa_server = start(&[parse_public_key(&authorized_private)]).await;
let rsa_sha1 = ssh(
&rsa_server,
&rsa_private,
"alice",
&["-o", "PubkeyAcceptedAlgorithms=ssh-rsa", "tit --version"],
);
assert!(
!rsa_sha1.status.success(),
"the server accepted RSA-SHA1: {}",
String::from_utf8_lossy(&rsa_sha1.stdout)
);
rsa_server
.shutdown()
.await
.expect("stop the RSA SSH server");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn limits_authentication_attempts_by_client_address() {
let directory = TempDir::new().expect("create a key directory");
let private_key = directory.path().join("ed25519");
generate_key(&private_key, KeyFixture::Ed25519);
let server = start(&[parse_public_key(&private_key)]).await;
for attempt in 0..30 {
let output = ssh(&server, &private_key, "alice", &["tit --version"]);
assert!(
output.status.success(),
"authentication attempt {} failed: {}",
attempt + 1,
String::from_utf8_lossy(&output.stderr)
);
}
let limited = ssh(&server, &private_key, "alice", &["tit --version"]);
assert!(!limited.status.success());
server.shutdown().await.expect("stop the SSH server");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rejects_shell_pty_arbitrary_exec_subsystem_agent_and_forwarding() {
let directory = TempDir::new().expect("create a key directory");
let private_key = directory.path().join("ed25519");
generate_key(&private_key, KeyFixture::Ed25519);
let key = parse_public_key(&private_key);
let server = start(&[key]).await;
assert!(!ssh(&server, &private_key, "alice", &[]).status.success());
assert!(
!ssh(&server, &private_key, "alice", &["uname", "-a"])
.status
.success()
);
assert!(
!ssh(&server, &private_key, "alice", &["-s", "sftp"])
.status
.success()
);
let pty = ssh(&server, &private_key, "alice", &["-tt", "tit --version"]);
assert!(
!pty.status.success() || !pty.stderr.is_empty(),
"the PTY request was not rejected"
);
assert!(
!ssh(&server, &private_key, "alice", &["-W", "127.0.0.1:1"])
.status
.success()
);
let agent_socket = directory.path().join("agent.sock");
let mut agent_process = start_agent(&agent_socket, &private_key);
let agent = ssh_with_env(
&server,
&private_key,
"alice",
&["-A", "tit --version"],
&[(
"SSH_AUTH_SOCK",
agent_socket.to_str().expect("a UTF-8 agent socket path"),
)],
);
agent_process.kill().expect("stop the test SSH agent");
agent_process.wait().expect("wait for the test SSH agent");
assert!(agent.status.success());
let audit = server.audit();
assert!(audit.rejected_shell >= 1);
assert!(audit.accepted_exec >= 2);
assert!(audit.rejected_pty >= 1);
assert!(audit.rejected_agent >= 1);
assert!(audit.rejected_forward >= 1);
server.shutdown().await.expect("stop the SSH server");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn accepts_only_strict_git_protocol_environment_values() {
let directory = TempDir::new().expect("create a key directory");
let private_key = directory.path().join("ed25519");
generate_key(&private_key, KeyFixture::Ed25519);
let key = parse_public_key(&private_key);
let server = start(&[key]).await;
for value in ["version=0", "version=1", "version=2"] {
let output = ssh_with_env(
&server,
&private_key,
"alice",
&["-o", "SendEnv=GIT_PROTOCOL", "tit --version"],
&[("GIT_PROTOCOL", value)],
);
assert!(output.status.success());
}
for value in ["", "2", "version=3", "version=2:extra", "version=2\nBAD=1"] {
let output = ssh_with_env(
&server,
&private_key,
"alice",
&["-o", "SendEnv=GIT_PROTOCOL", "tit --version"],
&[("GIT_PROTOCOL", value)],
);
assert!(output.status.success());
}
let unrelated = ssh_with_env(
&server,
&private_key,
"alice",
&["-o", "SendEnv=UNRELATED", "tit --version"],
&[("UNRELATED", "value")],
);
assert!(unrelated.status.success());
let audit = server.audit();
assert_eq!(audit.accepted_env, 3);
assert_eq!(audit.rejected_env, 6);
server.shutdown().await.expect("stop the SSH server");
}
async fn start(keys: &[SshPublicKey]) -> RunningSshServer {
RunningSshServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), keys)
.await
.expect("start the SSH server")
}
fn ssh(
server: &RunningSshServer,
private_key: &Path,
username: &str,
arguments: &[&str],
) -> Output {
ssh_with_env(server, private_key, username, arguments, &[])
}
fn ssh_with_env(
server: &RunningSshServer,
private_key: &Path,
username: &str,
arguments: &[&str],
environment: &[(&str, &str)],
) -> Output {
let mut command = Command::new("ssh");
command.args([
"-F",
"/dev/null",
"-o",
"BatchMode=yes",
"-o",
"IdentitiesOnly=yes",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"LogLevel=ERROR",
"-o",
"PreferredAuthentications=publickey",
"-o",
"PasswordAuthentication=no",
"-o",
"KbdInteractiveAuthentication=no",
"-o",
"ConnectTimeout=5",
"-i",
]);
command.arg(private_key);
command.args(["-p", &server.address().port().to_string()]);
command.arg(format!("{username}@{}", server.address().ip()));
command.args(arguments);
command.envs(environment.iter().copied());
command.output().expect("run the stock SSH client")
}
fn parse_public_key(private_key: &Path) -> SshPublicKey {
let encoded = fs::read_to_string(public_key_path(private_key)).expect("read the public key");
SshPublicKey::parse(&encoded).expect("parse the public key")
}
fn public_key_path(private_key: &Path) -> PathBuf {
let mut path = private_key.as_os_str().to_owned();
path.push(".pub");
PathBuf::from(path)
}
#[derive(Clone, Copy)]
enum KeyFixture {
Ed25519,
EcdsaP256,
Rsa3072,
}
impl KeyFixture {
fn name(self) -> &'static str {
match self {
Self::Ed25519 => "ed25519",
Self::EcdsaP256 => "ecdsa-p256",
Self::Rsa3072 => "rsa-3072",
}
}
}
fn generate_key(path: &Path, fixture: KeyFixture) {
let mut command = Command::new("ssh-keygen");
command.args(["-q", "-N", "", "-f"]);
command.arg(path);
match fixture {
KeyFixture::Ed25519 => {
command.args(["-t", "ed25519"]);
}
KeyFixture::EcdsaP256 => {
command.args(["-t", "ecdsa", "-b", "256"]);
}
KeyFixture::Rsa3072 => {
command.args(["-t", "rsa", "-b", "3072"]);
}
}
let output = command.output().expect("run ssh-keygen key generation");
assert!(
output.status.success(),
"generate {}: {}",
fixture.name(),
String::from_utf8_lossy(&output.stderr)
);
}
fn start_agent(socket: &Path, private_key: &Path) -> Child {
let mut child = Command::new("ssh-agent")
.args(["-D", "-a"])
.arg(socket)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("start the stock SSH agent");
let deadline = Instant::now() + Duration::from_secs(5);
while !socket.exists() {
if let Some(status) = child.try_wait().expect("inspect the SSH agent") {
panic!("SSH agent stopped before it was ready: {status}");
}
assert!(Instant::now() < deadline, "SSH agent was not ready");
thread::sleep(Duration::from_millis(10));
}
let output = Command::new("ssh-add")
.arg(private_key)
.env("SSH_AUTH_SOCK", socket)
.output()
.expect("add the test key to the SSH agent");
assert!(
output.status.success(),
"add the test key to the SSH agent: {}",
String::from_utf8_lossy(&output.stderr)
);
child
}