Initial commit

This commit is contained in:
pjht 2023-08-14 10:57:24 -05:00
commit 93e534cb71
Signed by: pjht
GPG Key ID: 7B5F6AFBEC7EE78E
4 changed files with 1734 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
data
config.toml

1334
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[package]
name = "ssh_ca"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.72"
chrono = "0.4.26"
clap = { version = "4.3.19", features = ["derive"] }
rand = "0.8.5"
serde = { version = "1.0.183", features = ["derive"] }
ssh-key = { version = "0.5.1", features = ["ed25519", "rsa", "encryption", "signature", "dsa", "p256", "p384", "rand_core"] }
toml = "0.7.6"

382
src/main.rs Normal file
View File

@ -0,0 +1,382 @@
use std::{
fmt::Display,
fs::File,
io::{self, Write},
path::{Path, PathBuf},
time::SystemTime,
};
use anyhow::anyhow;
use chrono::{Local, TimeZone};
use clap::{Parser, Subcommand};
use rand::RngCore;
use serde::{Serialize, Deserialize};
use ssh_key::{
certificate::{Builder as CertificateBuilder, CertType},
Algorithm, Certificate, PrivateKey, PublicKey, LineEnding,
};
#[derive(Debug, Parser)]
#[command(name = "ssh_ca")]
#[command(about = "A simple SSH CA", long_about = None)]
struct Cli {
#[arg(long = "config", default_value = "config.toml")]
config: PathBuf,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
/// Manage certificates
#[command(arg_required_else_help = true)]
Cert {
#[command(subcommand)]
command: CertCommands,
},
#[command(arg_required_else_help = true)]
/// Initialize CA
Init {
/// Path to CA data directory
data_dir: PathBuf,
}
}
#[derive(Debug, Subcommand)]
enum CertCommands {
/// Renew a certificate
#[command(arg_required_else_help = true)]
Renew {
/// Path to the certificate to renew, - for stdin (output defaults to stdout)
cert: String,
/// Output file - default is to overwrite input. Use - for stdout
#[arg(short = 'o', long = "out")]
output: Option<String>,
/// Overwrite output file even if it exists - only if different output file specified
#[arg(short = 'f', long = "force", requires("output"))]
force: bool,
},
/// Create a certificate
#[command(arg_required_else_help = true)]
Generate {
/// Generate a host certificate instead of a user certificate
#[arg(long = "host")]
host: bool,
/// Path to the key to sign, - for stdin (output defaults to stdout)
key: String,
/// Key ID
#[arg(short = 'I', long = "key-id")]
key_id: String,
/// Comma-separated list of valid principals
#[arg(short = 'p', long = "principals", required(true))]
principals: Option<String>,
/// Is the certificate valid for all principals - WARNING: This creates a certificate that
/// can sign in as any user / act as any host, be careful
#[arg(
long = "valid-for-all-principals-yes-i-know-what-im-doing",
conflicts_with("principals")
)]
all_principals: bool,
/// Output file - default is -cert appended to file name before extension (mykey.pub ->
/// mykey-cert.pub). Use - for stdout
#[arg(short = 'o', long = "out")]
output: Option<String>,
/// Overwrite output file even if it exists
#[arg(short = 'f', long = "force")]
force: bool,
},
/// Print info about a certificate
Info {
/// Path to the certificate to show, - for stdin
cert: String,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Config {
data_dir: PathBuf,
}
struct CertificateFormatter<'a>(&'a Certificate);
impl Display for CertificateFormatter<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
fn print_pubkey_alg(
algorithm: &Algorithm,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
match algorithm {
ssh_key::Algorithm::Dsa => f.write_str("DSA")?,
ssh_key::Algorithm::Ecdsa { .. } => f.write_str("ECDSA")?,
ssh_key::Algorithm::Ed25519 => f.write_str("ED25519")?,
ssh_key::Algorithm::Rsa { .. } => f.write_str("RSA")?,
ssh_key::Algorithm::SkEcdsaSha2NistP256 => f.write_str("ECDSA-SK")?,
ssh_key::Algorithm::SkEd25519 => f.write_str("ED25519-SK")?,
_ => todo!(),
};
Ok(())
}
f.write_fmt(format_args!(
"Type: {} ",
self.0.algorithm().as_certificate_str()
))?;
match self.0.cert_type() {
CertType::User => f.write_str("user certificate\n")?,
CertType::Host => f.write_str("host certificate\n")?,
};
f.write_str("Public Key: ")?;
print_pubkey_alg(&self.0.public_key().algorithm(), f)?;
f.write_fmt(format_args!(
"-CERT {}\n",
self.0.public_key().fingerprint(Default::default())
))?;
f.write_str("Signing CA: ")?;
print_pubkey_alg(&self.0.signature_key().algorithm(), f)?;
f.write_fmt(format_args!(
" {} (using {})\n",
self.0.signature_key().fingerprint(Default::default()),
&self.0.signature_key().algorithm()
))?;
f.write_fmt(format_args!("Key ID: \"{}\"\n", self.0.key_id()))?;
f.write_fmt(format_args!("Serial: {}\n", self.0.serial()))?;
f.write_fmt(format_args!(
"Valid: from {} to {}\n",
Local
.timestamp_opt(self.0.valid_after() as i64, 0)
.unwrap()
.to_rfc3339(),
Local
.timestamp_opt(self.0.valid_before() as i64, 0)
.unwrap()
.to_rfc3339()
))?;
f.write_str("Principals:")?;
if self.0.valid_principals().is_empty() {
f.write_str(" (none)\n")?;
} else {
f.write_str("\n")?;
for principal in self.0.valid_principals() {
f.write_fmt(format_args!("\t{}\n", principal))?;
}
}
f.write_str("Critical Options:")?;
if self.0.critical_options().is_empty() {
f.write_str(" (none)\n")?;
} else {
f.write_str("\n")?;
for (name, _) in self.0.critical_options().iter() {
f.write_fmt(format_args!("\t{}\n", name))?;
}
}
f.write_str("Extensions:")?;
if self.0.extensions().is_empty() {
f.write_str(" (none)\n")?;
} else {
for (name, _) in self.0.extensions().iter() {
f.write_fmt(format_args!("\n\t{}", name))?;
}
}
Ok(())
}
}
fn main() -> anyhow::Result<()> {
let args = Cli::parse();
let mut rng = rand::thread_rng();
if let Commands::Init { data_dir } = args.command {
if data_dir.exists() {
return Err(anyhow!("Data directory exists"));
}
std::fs::create_dir(&data_dir)?;
let host_ca_key = PrivateKey::random(rand::thread_rng(), Algorithm::Ed25519)?;
let user_ca_key = PrivateKey::random(rand::thread_rng(), Algorithm::Ed25519)?;
std::fs::write(data_dir.join("host_ca_key"), host_ca_key.to_openssh(LineEnding::default())?)?;
std::fs::write(data_dir.join("host_ca_key.pub"), host_ca_key.public_key().to_openssh()?)?;
std::fs::write(data_dir.join("user_ca_key"), user_ca_key.to_openssh(LineEnding::default())?)?;
std::fs::write(data_dir.join("user_ca_key.pub"), user_ca_key.public_key().to_openssh()?)?;
let config = Config {
data_dir: data_dir.clone(),
};
std::fs::write(args.config, toml::to_string(&config)?)?;
return Ok(());
}
let config: Config = toml::from_str(&std::fs::read_to_string(args.config)?)?;
let host_ca_key = PrivateKey::read_openssh_file(&config.data_dir.join("ssh_host_ca"))?;
let user_ca_key = PrivateKey::read_openssh_file(&config.data_dir.join("ssh_user_ca"))?;
match args.command {
Commands::Cert { command } => match command {
CertCommands::Renew {
cert: cert_path,
output,
force,
} => {
let old_cert = if cert_path == "-" {
let mut cert_str = String::new();
io::stdin().read_line(&mut cert_str)?;
Certificate::from_openssh(&cert_str)?
} else {
Certificate::read_file(Path::new(&cert_path))?
};
let fingerprint = match old_cert.cert_type() {
CertType::User => &user_ca_key,
CertType::Host => &host_ca_key,
}
.fingerprint(Default::default());
let cert_valid =
match old_cert.validate_at(old_cert.valid_after(), Some(&fingerprint)) {
Ok(_) => true,
Err(ssh_key::Error::CertificateValidation) => false,
Err(e) => Err(e)?,
};
if !cert_valid {
eprintln!("Error: certificate not signed by this ca");
return Ok(());
};
println!("Old certificate:");
println!("{}", CertificateFormatter(&old_cert));
let mut nonce = vec![0u8; CertificateBuilder::RECOMMENDED_NONCE_SIZE];
rng.fill_bytes(&mut nonce);
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)?
.as_secs();
let mut cert_builder = CertificateBuilder::new(
nonce,
old_cert.public_key().clone(),
now,
now + (3600 * 24 * 365 * 2),
);
cert_builder.cert_type(old_cert.cert_type())?;
cert_builder.comment(old_cert.comment())?;
for (name, data) in old_cert.critical_options().iter() {
cert_builder.critical_option(name, data)?;
}
for (name, data) in old_cert.extensions().iter() {
cert_builder.extension(name, data)?;
}
cert_builder.key_id(old_cert.key_id())?;
for principal in old_cert.valid_principals() {
cert_builder.valid_principal(principal)?;
}
let cert = if old_cert.cert_type().is_host() {
cert_builder.sign(&host_ca_key)?
} else {
cert_builder.sign(&user_ca_key)?
};
println!("New certificate:");
println!("{}", CertificateFormatter(&cert));
if output.as_ref().map_or(false, |output| output == "-")
|| (cert_path == "-" && output.as_ref().is_none())
{
println!("{}", cert.to_openssh()?);
} else {
let output = if let Some(output) = output {
if Path::new(&output).exists() && !force {
eprintln!("{} already exists. Use -f to overwrite.", output);
return Ok(());
}
output
} else {
cert_path
};
let mut file = File::create(&output)?;
write!(file, "{}", cert.to_openssh()?)?;
}
}
CertCommands::Generate {
host,
key: key_path,
key_id,
principals,
all_principals,
output,
force,
} => {
let key = if key_path == "-" {
let mut key_str = String::new();
io::stdin().read_line(&mut key_str)?;
PublicKey::from_openssh(&key_str)?
} else {
PublicKey::read_openssh_file(Path::new(&key_path))?
};
let mut nonce = vec![0u8; CertificateBuilder::RECOMMENDED_NONCE_SIZE];
rng.fill_bytes(&mut nonce);
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)?
.as_secs();
let mut cert_builder =
CertificateBuilder::new(nonce, key, now, now + (3600 * 24 * 365 * 2));
if host {
cert_builder.cert_type(CertType::Host)?;
} else {
cert_builder.extension("permit-X11-forwarding", "")?;
cert_builder.extension("permit-agent-forwarding", "")?;
cert_builder.extension("permit-port-forwarding", "")?;
cert_builder.extension("permit-pty", "")?;
cert_builder.extension("permit-user-rc", "")?;
}
if all_principals {
cert_builder.all_principals_valid()?;
} else {
for principal in principals.unwrap().split(',') {
cert_builder.valid_principal(principal)?;
}
}
cert_builder.key_id(key_id)?;
let cert = if host {
cert_builder.sign(&host_ca_key)?
} else {
cert_builder.sign(&user_ca_key)?
};
if output.as_ref().map_or(false, |output| output == "-")
|| (key_path == "-" && output.as_ref().is_none())
{
println!("{}", cert.to_openssh()?);
} else {
let output = if let Some(output) = output {
output
} else {
let ext = Path::new(&key_path)
.extension()
.map_or(String::new(), |ext| {
".".to_string() + &ext.to_string_lossy()
});
key_path.strip_suffix(&ext).unwrap().to_owned() + "-cert" + &ext
};
if Path::new(&output).exists() && !force {
eprintln!("{} already exists. Use -f to overwrite.", output);
} else {
let mut file = File::create(&output)?;
write!(file, "{}", cert.to_openssh()?)?;
}
}
},
CertCommands::Info { cert: cert_path } => {
let cert = if cert_path == "-" {
let mut cert_str = String::new();
io::stdin().read_line(&mut cert_str)?;
Certificate::from_openssh(&cert_str)?
} else {
Certificate::read_file(Path::new(&cert_path))?
};
let fingerprint = match cert.cert_type() {
CertType::User => &user_ca_key,
CertType::Host => &host_ca_key,
}
.fingerprint(Default::default());
let cert_valid =
match cert.validate_at(cert.valid_after(), Some(&fingerprint)) {
Ok(_) => true,
Err(ssh_key::Error::CertificateValidation) => false,
Err(e) => Err(e)?,
};
if !cert_valid {
eprintln!("Warning: certificate not signed by this ca");
};
println!("{}", CertificateFormatter(&cert));
dbg!(cert.to_bytes()?.len());
}
},
Commands::Init { .. } => unreachable!(),
}
Ok(())
}