fix: add ssh-key dev-dependency for server_auth tests
This commit is contained in:
374
crates/wraith-core/src/auth/server_auth.rs
Normal file
374
crates/wraith-core/src/auth/server_auth.rs
Normal file
@@ -0,0 +1,374 @@
|
||||
use std::collections::HashSet;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use ipnetwork::IpNetwork;
|
||||
use russh::keys::{Certificate, PublicKey};
|
||||
|
||||
use super::keys::{CertAuthorityEntry, KeySource, load_cert_authority_entries, load_public_keys};
|
||||
use crate::error::AuthError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerAuthConfig {
|
||||
pub authorized_keys: HashSet<PublicKey>,
|
||||
pub cert_authorities: Vec<CertAuthorityEntry>,
|
||||
}
|
||||
|
||||
impl ServerAuthConfig {
|
||||
pub fn from_keys_and_ca(
|
||||
authorized_keys_source: Option<KeySource>,
|
||||
cert_authority_source: Option<KeySource>,
|
||||
) -> Result<Self, crate::error::ConfigError> {
|
||||
let authorized_keys = match authorized_keys_source {
|
||||
Some(src) => load_public_keys(src)?.into_iter().collect(),
|
||||
None => HashSet::new(),
|
||||
};
|
||||
|
||||
let cert_authorities = match cert_authority_source {
|
||||
Some(src) => load_cert_authority_entries(src)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
Ok(ServerAuthConfig {
|
||||
authorized_keys,
|
||||
cert_authorities,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn authenticate_publickey(&self, key: &PublicKey) -> Result<(), AuthError> {
|
||||
if self.authorized_keys.contains(key) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AuthError::KeyRejected)
|
||||
}
|
||||
|
||||
pub fn authenticate_certificate(
|
||||
&self,
|
||||
cert: &Certificate,
|
||||
user: &str,
|
||||
client_ip: Option<IpAddr>,
|
||||
) -> Result<(), AuthError> {
|
||||
let matching_ca = self
|
||||
.cert_authorities
|
||||
.iter()
|
||||
.find(|ca| cert.signature_key() == ca.public_key.key_data());
|
||||
|
||||
let ca_entry = match matching_ca {
|
||||
Some(entry) => entry,
|
||||
None => return Err(AuthError::CertInvalid),
|
||||
};
|
||||
|
||||
if cert.verify_signature().is_err() {
|
||||
return Err(AuthError::CertInvalid);
|
||||
}
|
||||
|
||||
let now = SystemTime::now();
|
||||
let now_secs = now
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
if now_secs < cert.valid_after() || now_secs >= cert.valid_before() {
|
||||
return Err(AuthError::CertExpired);
|
||||
}
|
||||
|
||||
let principals = cert.valid_principals();
|
||||
if !principals.is_empty() && !principals.iter().any(|p| p == user) {
|
||||
return Err(AuthError::CertPrincipalMismatch);
|
||||
}
|
||||
|
||||
check_critical_options(cert, ca_entry, client_ip)?;
|
||||
|
||||
check_extensions(cert, ca_entry)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn check_critical_options(
|
||||
cert: &Certificate,
|
||||
ca_entry: &CertAuthorityEntry,
|
||||
client_ip: Option<IpAddr>,
|
||||
) -> Result<(), AuthError> {
|
||||
let ca_has_no_pty = ca_entry.options.iter().any(|o| o == "no-pty");
|
||||
|
||||
for (name, data) in cert.critical_options().iter() {
|
||||
match name.as_str() {
|
||||
"no-pty" => {}
|
||||
"source-address" => {
|
||||
if !check_source_address(data, client_ip) {
|
||||
return Err(AuthError::CertInvalid);
|
||||
}
|
||||
}
|
||||
"force-command" => {}
|
||||
_ => {
|
||||
let _ = ca_has_no_pty;
|
||||
return Err(AuthError::CertInvalid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_extensions(
|
||||
cert: &Certificate,
|
||||
ca_entry: &CertAuthorityEntry,
|
||||
) -> Result<(), AuthError> {
|
||||
let ca_permit_port_forwarding = ca_entry
|
||||
.options
|
||||
.iter()
|
||||
.any(|o| o == "permit-port-forwarding");
|
||||
|
||||
if ca_permit_port_forwarding {
|
||||
let cert_allows = cert
|
||||
.extensions()
|
||||
.iter()
|
||||
.any(|(n, _)| n == "permit-port-forwarding");
|
||||
if !cert_allows {
|
||||
return Err(AuthError::CertInvalid);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_source_address(allowed: &str, client_ip: Option<IpAddr>) -> bool {
|
||||
let Some(ip) = client_ip else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for pattern in allowed.split(',') {
|
||||
let pattern = pattern.trim();
|
||||
if pattern.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(cidr) = IpNetwork::from_str(pattern) {
|
||||
if cidr.contains(ip) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(net_ip) = IpAddr::from_str(pattern) {
|
||||
if net_ip == ip {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand_core::OsRng;
|
||||
use russh::keys::{Certificate, PrivateKey, decode_secret_key};
|
||||
use russh::keys::certificate::{Builder, CertType};
|
||||
use std::io::Write;
|
||||
|
||||
const CA_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
const USER_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBIeLC1lWiCYrXsf/85O/pkbUFZ6OGIt49PX3nw8iRoXEAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBIeLC1lWiCYrXsf/85O/pkbUFZ6OGIt49PX3nw8iRoXE\nAAAECN7VPGq3dipvy5bJjpJCxbCDdJd7lf7D8sWsmCl7A2fR4sIWVaIJitex//zk7+mRtQ\nVno4Yi3j09fefDyJGhcQAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
const OTHER_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDZ5eU7qBc8pjN0Vw2WU4fB3kY3F7UZ3WwN8y2b/KvDwAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACDZ5eU7qBc8pjN0Vw2WU4fB3kY3F7UZ3WwN8y2b/KvDw\nAAAEAy8qZ3R5T2p4V1iS5OzYHjf3Hb4a5kS3+4M0QYI7kWg2fl7TuoFzymM3RXDZZTh8He\nRjcXtRndbA3zLZv8q8PAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
fn load_ca_key() -> PrivateKey {
|
||||
decode_secret_key(CA_PRIVATE_KEY, None).unwrap()
|
||||
}
|
||||
|
||||
fn load_user_key() -> PrivateKey {
|
||||
decode_secret_key(USER_PRIVATE_KEY, None).unwrap()
|
||||
}
|
||||
|
||||
fn load_other_key() -> PrivateKey {
|
||||
decode_secret_key(OTHER_PRIVATE_KEY, None).unwrap()
|
||||
}
|
||||
|
||||
fn make_cert(
|
||||
ca_key: &PrivateKey,
|
||||
user_key: &PublicKey,
|
||||
valid_after: u64,
|
||||
valid_before: u64,
|
||||
principals: Vec<&str>,
|
||||
) -> Certificate {
|
||||
let mut builder = Builder::new_with_random_nonce(
|
||||
&mut OsRng,
|
||||
user_key.key_data().clone(),
|
||||
valid_after,
|
||||
valid_before,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
builder.cert_type(CertType::User).unwrap();
|
||||
|
||||
for p in principals {
|
||||
builder.valid_principal(p).unwrap();
|
||||
}
|
||||
|
||||
builder.sign(ca_key).unwrap()
|
||||
}
|
||||
|
||||
fn make_authorized_keys_file(keys: &[&PublicKey]) -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||
for key in keys {
|
||||
let line = format!("{}\n", key.to_openssh().unwrap());
|
||||
f.write_all(line.as_bytes()).unwrap();
|
||||
}
|
||||
f.flush().unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
fn make_ca_file(ca_pub: &PublicKey, options: &[&str]) -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||
let opts = if options.is_empty() {
|
||||
"cert-authority".to_string()
|
||||
} else {
|
||||
format!("cert-authority,{}", options.join(","))
|
||||
};
|
||||
let line = format!(
|
||||
"{} {} CA\n",
|
||||
opts,
|
||||
ca_pub.to_openssh().unwrap()
|
||||
);
|
||||
f.write_all(line.as_bytes()).unwrap();
|
||||
f.flush().unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_key_accepted() {
|
||||
let user_key = load_user_key();
|
||||
let user_pub = user_key.public_key().clone();
|
||||
let f = make_authorized_keys_file(&[&user_pub]);
|
||||
let config =
|
||||
ServerAuthConfig::from_keys_and_ca(Some(KeySource::File(f.path().to_path_buf())), None)
|
||||
.unwrap();
|
||||
assert!(config.authenticate_publickey(&user_pub).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_key_rejected() {
|
||||
let user_key = load_user_key();
|
||||
let other_key = load_other_key();
|
||||
let user_pub = user_key.public_key().clone();
|
||||
let other_pub = other_key.public_key().clone();
|
||||
let f = make_authorized_keys_file(&[&user_pub]);
|
||||
let config =
|
||||
ServerAuthConfig::from_keys_and_ca(Some(KeySource::File(f.path().to_path_buf())), None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config.authenticate_publickey(&other_pub),
|
||||
Err(AuthError::KeyRejected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_authority_signed_cert_accepted() {
|
||||
let ca_key = load_ca_key();
|
||||
let user_key = load_user_key();
|
||||
let ca_pub = ca_key.public_key().clone();
|
||||
let user_pub = user_key.public_key().clone();
|
||||
let now = now_secs();
|
||||
let cert = make_cert(&ca_key, &user_pub, now - 60, now + 3600, vec!["testuser"]);
|
||||
let f = make_ca_file(&ca_pub, &[]);
|
||||
let config =
|
||||
ServerAuthConfig::from_keys_and_ca(None, Some(KeySource::File(f.path().to_path_buf())))
|
||||
.unwrap();
|
||||
assert!(config
|
||||
.authenticate_certificate(&cert, "testuser", None)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_cert_rejected() {
|
||||
let ca_key = load_ca_key();
|
||||
let user_key = load_user_key();
|
||||
let ca_pub = ca_key.public_key().clone();
|
||||
let user_pub = user_key.public_key().clone();
|
||||
let now = now_secs();
|
||||
let cert = make_cert(&ca_key, &user_pub, now - 7200, now - 3600, vec!["testuser"]);
|
||||
let f = make_ca_file(&ca_pub, &[]);
|
||||
let config =
|
||||
ServerAuthConfig::from_keys_and_ca(None, Some(KeySource::File(f.path().to_path_buf())))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config.authenticate_certificate(&cert, "testuser", None),
|
||||
Err(AuthError::CertExpired)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_principal_rejected() {
|
||||
let ca_key = load_ca_key();
|
||||
let user_key = load_user_key();
|
||||
let ca_pub = ca_key.public_key().clone();
|
||||
let user_pub = user_key.public_key().clone();
|
||||
let now = now_secs();
|
||||
let cert = make_cert(&ca_key, &user_pub, now - 60, now + 3600, vec!["alice"]);
|
||||
let f = make_ca_file(&ca_pub, &[]);
|
||||
let config =
|
||||
ServerAuthConfig::from_keys_and_ca(None, Some(KeySource::File(f.path().to_path_buf())))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config.authenticate_certificate(&cert, "bob", None),
|
||||
Err(AuthError::CertPrincipalMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_empty_principals_accepts_any_user() {
|
||||
let ca_key = load_ca_key();
|
||||
let user_key = load_user_key();
|
||||
let ca_pub = ca_key.public_key().clone();
|
||||
let user_pub = user_key.public_key().clone();
|
||||
let now = now_secs();
|
||||
let cert = make_cert(&ca_key, &user_pub, now - 60, now + 3600, vec![]);
|
||||
let f = make_ca_file(&ca_pub, &[]);
|
||||
let config =
|
||||
ServerAuthConfig::from_keys_and_ca(None, Some(KeySource::File(f.path().to_path_buf())))
|
||||
.unwrap();
|
||||
assert!(config
|
||||
.authenticate_certificate(&cert, "anyuser", None)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_wrong_ca_rejected() {
|
||||
let user_key = load_user_key();
|
||||
let other_ca_key = load_other_key();
|
||||
let user_pub = user_key.public_key().clone();
|
||||
let now = now_secs();
|
||||
let cert = make_cert(&other_ca_key, &user_pub, now - 60, now + 3600, vec!["testuser"]);
|
||||
let ca_key = load_ca_key();
|
||||
let ca_pub = ca_key.public_key().clone();
|
||||
let f = make_ca_file(&ca_pub, &[]);
|
||||
let config =
|
||||
ServerAuthConfig::from_keys_and_ca(None, Some(KeySource::File(f.path().to_path_buf())))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config.authenticate_certificate(&cert, "testuser", None),
|
||||
Err(AuthError::CertInvalid)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_config_accepts_nothing() {
|
||||
let config =
|
||||
ServerAuthConfig::from_keys_and_ca(None, None).unwrap();
|
||||
let other_pub = load_other_key().public_key().clone();
|
||||
assert_eq!(
|
||||
config.authenticate_publickey(&other_pub),
|
||||
Err(AuthError::KeyRejected)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user