Initial commit
This commit is contained in:
commit
93e534cb71
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/target
|
||||
data
|
||||
config.toml
|
1334
Cargo.lock
generated
Normal file
1334
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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
382
src/main.rs
Normal 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(())
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user