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