Initial commit
This commit is contained in:
2
tests/common/mod.rs
Normal file
2
tests/common/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod setup;
|
||||
pub mod token;
|
||||
71
tests/common/setup.rs
Normal file
71
tests/common/setup.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use std::error::Error;
|
||||
use std::io::prelude::*;
|
||||
use std::path::Path;
|
||||
use std::sync::Once;
|
||||
|
||||
use rand_core::OsRng;
|
||||
use ssh_key::{Algorithm, Fingerprint, PrivateKey, PublicKey};
|
||||
use tempfile::NamedTempFile;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use sshca::config::Configuration;
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
fn gen_machine_ids() -> Result<NamedTempFile, Box<dyn Error>> {
|
||||
let f = NamedTempFile::new()?;
|
||||
let map = serde_json::json!({
|
||||
"test.example.org": "b75e9126-d73a-4ae0-9a0d-63cb3552e6cd",
|
||||
});
|
||||
serde_json::to_writer(&f, &map)?;
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
fn gen_config(machine_ids: &Path, host_key: &Path) -> Configuration {
|
||||
let mut config = Configuration {
|
||||
machine_ids: machine_ids.to_str().unwrap().into(),
|
||||
..Default::default()
|
||||
};
|
||||
config.ca.host.private_key_file = host_key.to_str().unwrap().into();
|
||||
config
|
||||
}
|
||||
|
||||
fn gen_ca_key() -> Result<(NamedTempFile, PublicKey), Box<dyn Error>> {
|
||||
let key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519)?;
|
||||
let mut f = NamedTempFile::new()?;
|
||||
f.write_all(key.to_openssh(Default::default())?.as_bytes())?;
|
||||
Ok((f, key.public_key().clone()))
|
||||
}
|
||||
|
||||
pub async fn setup() -> Result<(TestContext, Configuration), Box<dyn Error>> {
|
||||
INIT.call_once(|| {
|
||||
tracing_subscriber::fmt::fmt()
|
||||
.with_env_filter(EnvFilter::from("sshca=trace"))
|
||||
.with_test_writer()
|
||||
.init();
|
||||
});
|
||||
|
||||
let machine_ids = gen_machine_ids()?;
|
||||
let (host_key, host_key_pub) = gen_ca_key()?;
|
||||
let config = gen_config(machine_ids.path(), host_key.path());
|
||||
|
||||
let ctx = TestContext {
|
||||
machine_ids,
|
||||
host_key,
|
||||
host_key_pub,
|
||||
};
|
||||
Ok((ctx, config))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct TestContext {
|
||||
machine_ids: NamedTempFile,
|
||||
host_key: NamedTempFile,
|
||||
host_key_pub: PublicKey,
|
||||
}
|
||||
|
||||
impl TestContext {
|
||||
pub fn host_ca_fingerprint(&self) -> Fingerprint {
|
||||
self.host_key_pub.fingerprint(Default::default())
|
||||
}
|
||||
}
|
||||
41
tests/common/token.rs
Normal file
41
tests/common/token.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::time;
|
||||
|
||||
use argon2::Argon2;
|
||||
use jsonwebtoken::{encode, EncodingKey};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TestClaims {
|
||||
sub: String,
|
||||
iss: String,
|
||||
aud: String,
|
||||
iat: u64,
|
||||
nbf: u64,
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
pub fn make_token(hostname: &str, machine_id: Uuid) -> String {
|
||||
let now = time::SystemTime::now()
|
||||
.duration_since(time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let claims = TestClaims {
|
||||
sub: hostname.into(),
|
||||
iss: hostname.into(),
|
||||
aud: "sshca.example.org".into(),
|
||||
nbf: now - 60,
|
||||
iat: now,
|
||||
exp: now + 60,
|
||||
};
|
||||
let mut secret = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(
|
||||
machine_id.as_bytes(),
|
||||
hostname.as_bytes(),
|
||||
&mut secret,
|
||||
)
|
||||
.unwrap();
|
||||
let key = EncodingKey::from_secret(&secret);
|
||||
encode(&Default::default(), &claims, &key).unwrap()
|
||||
}
|
||||
209
tests/test_host.rs
Normal file
209
tests/test_host.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use form_data_builder::FormData;
|
||||
use ssh_key::{Algorithm, Certificate};
|
||||
use tower::ServiceExt;
|
||||
use uuid::uuid;
|
||||
|
||||
use sshca::server::make_app;
|
||||
|
||||
use common::setup;
|
||||
use common::token;
|
||||
|
||||
const ED25519_KEY: &str = concat!(
|
||||
"ssh-ed25519 ",
|
||||
"AAAAC3NzaC1lZDI1NTE5AAAAIAsFEmrNIoRHHUayEO0NdAIgtMvci/wME07h+A5XSNJy",
|
||||
);
|
||||
const DSA_KEY: &str = concat!(
|
||||
"ssh-dss ",
|
||||
"AAAAB3NzaC1kc3MAAACBALNAS+fZjaWt4q+MAgjf6HREFoYjgoSVJUUCtNmRGhND85msVtla",
|
||||
"kll2gLzL6n6TWyiToARlThoTFu1ZDoGYauDL7iDXrGB6VWJEOQZ3TEMHLFYPziW02AbjR9GI",
|
||||
"ptsF42D0bTTvvaIaBIhOTjWAUjuFIhAKhPkcj+udIcyH8CG1AAAAFQCpbXQSlxOvd4J92j2C",
|
||||
"rWDYVGoK8wAAAIAfHiV6/glGZrDRztJmw1hfwbmiNPxaoSGkB+Necfkj0fZrlyLj8sLJIbGQ",
|
||||
"w0dJMATZdRHw3Ql4R5IOu7sBfX1KQW++onT4ads/Xtl6vwfsjO2e/a6Y1ib9JCIOGJxNAAUC",
|
||||
"JU0Fm0TSv2Nn6UTICAarp1eKALimqkvy1+ygBWjprgAAAIEAic5EpZH9wpgzvl9kPW531yrz",
|
||||
"IOlCcXsJFPqQxUThrB2o1g3Rjpscd9kCw5UlPu6GGLk4aSN3UxeIKymTuKiEi7tvP1Tj/Bv5",
|
||||
"tEc4rhfmrBAfAST09oRFDsELufsOAlTrJ0uk2LhtN14H1RBv9qPR5PQKTEYslyvXG1f8itNQ",
|
||||
"YnQ="
|
||||
);
|
||||
|
||||
fn make_test_request_body(key: &[u8], name: &str) -> (Body, String) {
|
||||
let mut form = FormData::new(Vec::new());
|
||||
form.write_file(
|
||||
"pubkey",
|
||||
key,
|
||||
Some(name.as_ref()),
|
||||
"application/octet-stream",
|
||||
)
|
||||
.unwrap();
|
||||
let content_type = form.content_type_header();
|
||||
let body = Body::from(form.finish().unwrap());
|
||||
(body, content_type)
|
||||
}
|
||||
|
||||
fn make_test_request(body: Body, content_type: &str) -> Request<Body> {
|
||||
let hostname = "test.example.org";
|
||||
let machine_id = uuid!("b75e9126-d73a-4ae0-9a0d-63cb3552e6cd");
|
||||
let token = token::make_token(hostname, machine_id);
|
||||
Request::builder()
|
||||
.uri("/host/sign")
|
||||
.method("POST")
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Host", "sshca.example.org")
|
||||
.header("Content-Type", content_type)
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign() {
|
||||
let (ctx, config) = setup::setup().await.unwrap();
|
||||
|
||||
let app = make_app(config);
|
||||
let (body, content_type) = make_test_request_body(
|
||||
ED25519_KEY.as_bytes(),
|
||||
"ssh_host_ed25519_key.pub",
|
||||
);
|
||||
let req = make_test_request(body, &content_type);
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
let body = hyper::body::to_bytes(res.into_body()).await.unwrap();
|
||||
let cert = Certificate::from_openssh(std::str::from_utf8(&body).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(cert.algorithm(), Algorithm::Ed25519);
|
||||
cert.validate(&[ctx.host_ca_fingerprint()]).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign_invalid() {
|
||||
let (_ctx, config) = setup::setup().await.unwrap();
|
||||
|
||||
let (body, content_type) = make_test_request_body(
|
||||
"this is not a valid openssh key".as_bytes(),
|
||||
"ssh_host_ecdsa_key.pub",
|
||||
);
|
||||
let app = make_app(config);
|
||||
let req = make_test_request(body, &content_type);
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body = hyper::body::to_bytes(res.into_body()).await.unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
concat!(
|
||||
"Could not parse SSH key: ",
|
||||
"Base64 encoding error: invalid Base64 encoding",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign_nokey() {
|
||||
let (_ctx, config) = setup::setup().await.unwrap();
|
||||
|
||||
let mut form = FormData::new(Vec::new());
|
||||
let content_type = form.content_type_header();
|
||||
let body = Body::from(form.finish().unwrap());
|
||||
let app = make_app(config);
|
||||
let req = make_test_request(body, &content_type);
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body = hyper::body::to_bytes(res.into_body()).await.unwrap();
|
||||
assert_eq!(body, "No SSH public key provided in request",);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign_mangled() {
|
||||
let (_ctx, config) = setup::setup().await.unwrap();
|
||||
|
||||
let app = make_app(config);
|
||||
let mut form = FormData::new(Vec::new());
|
||||
form.write_file(
|
||||
"pubkey",
|
||||
ED25519_KEY.as_bytes(),
|
||||
Some("ssh_host_ed25519_key.pub".as_ref()),
|
||||
"application/octet-stream",
|
||||
)
|
||||
.unwrap();
|
||||
let content_type = form.content_type_header();
|
||||
let mut form_bytes = form.finish().unwrap();
|
||||
form_bytes.truncate(19);
|
||||
let body = Body::from(form_bytes);
|
||||
let req = make_test_request(body, &content_type);
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body = hyper::body::to_bytes(res.into_body()).await.unwrap();
|
||||
assert_eq!(body, "Error parsing `multipart/form-data` request",);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign_bad_request() {
|
||||
let (_ctx, config) = setup::setup().await.unwrap();
|
||||
|
||||
let app = make_app(config);
|
||||
let content_type = "text/plain";
|
||||
let body = Body::from("test");
|
||||
let req = make_test_request(body, content_type);
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body = hyper::body::to_bytes(res.into_body()).await.unwrap();
|
||||
assert_eq!(body, "Invalid `boundary` for `multipart/form-data` request",);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign_dsa() {
|
||||
let (_ctx, config) = setup::setup().await.unwrap();
|
||||
|
||||
let app = make_app(config);
|
||||
let (body, content_type) =
|
||||
make_test_request_body(DSA_KEY.as_bytes(), "ssh_host_dsa_key.pub");
|
||||
let req = make_test_request(body, &content_type);
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body = hyper::body::to_bytes(res.into_body()).await.unwrap();
|
||||
assert_eq!(body, "Unsupported key algorithm: ssh-dss");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign_failure() {
|
||||
let (_ctx, mut config) = setup::setup().await.unwrap();
|
||||
config.ca.host.private_key_file = "bogus".into();
|
||||
|
||||
let app = make_app(config);
|
||||
let (body, content_type) = make_test_request_body(
|
||||
ED25519_KEY.as_bytes(),
|
||||
"ssh_host_ed25519_key.pub",
|
||||
);
|
||||
let req = make_test_request(body, &content_type);
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = hyper::body::to_bytes(res.into_body()).await.unwrap();
|
||||
assert_eq!(body, "Service Unavailable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sign_unauthorized() {
|
||||
// Deliberately drop the TestContext so the machine ID file gets deleted,
|
||||
// which will cause authentication to fail.
|
||||
let (_, config) = setup::setup().await.unwrap();
|
||||
|
||||
let app = make_app(config);
|
||||
let (body, content_type) = make_test_request_body(
|
||||
ED25519_KEY.as_bytes(),
|
||||
"ssh_host_ed25519_key.pub",
|
||||
);
|
||||
let req = make_test_request(body, &content_type);
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = hyper::body::to_bytes(res.into_body()).await.unwrap();
|
||||
assert_eq!(body, "Unauthorized");
|
||||
}
|
||||
Reference in New Issue
Block a user