diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 5e406e9..691684b 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -60,11 +60,11 @@ jobs: run: cargo doc --all-features --no-deps msrv: - name: MSRV (1.85) + name: MSRV (1.88) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.88 - uses: Swatinem/rust-cache@v2 - name: cargo check run: cargo check --all-features diff --git a/Cargo.toml b/Cargo.toml index a2730da..2dfb0a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,42 +1,65 @@ -[package] -name = "certkit" -version = "0.2.0" +[workspace] +members = ["certkit-cli"] +resolver = "2" + +[workspace.package] edition = "2024" -rust-version = "1.85" +rust-version = "1.88" license = "MIT OR Apache-2.0" -description = "A pure Rust library for X.509 certificate creation, parsing, and management, supporting RSA, ECDSA, and Ed25519 keys, with no OpenSSL or ring dependencies." repository = "https://github.com/nacardin/certkit.git" homepage = "https://github.com/nacardin/certkit" +authors = ["Nick Cardin "] + +[workspace.dependencies] +const-oid = { version = "0.9.6", features = ["db"] } +der = "0.7" +env_logger = "0.11" +log = "0.4" +rsa = "0.9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = { version = "0.10", default-features = false, features = ["oid"] } +time = "0.3" +x509-cert = "0.2" + +[package] +name = "certkit" +version = "0.2.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "A pure Rust library for X.509 certificate creation, parsing, and management, supporting RSA, ECDSA, and Ed25519 keys, with no OpenSSL or ring dependencies." +repository.workspace = true +homepage.workspace = true documentation = "https://docs.rs/certkit" readme = "README.md" keywords = ["x509", "certificate", "crypto", "pki", "tls"] categories = ["cryptography", "authentication"] -authors = ["Nick Cardin "] +authors.workspace = true [features] default = ["rsa", "p256", "p384", "p521", "ed25519"] ed25519 = ["dep:ed25519-dalek"] - [dependencies] bon = "3" -const-oid = { version = "0.9.6", features = ["db"] } -der = "0.7" +const-oid.workspace = true +der.workspace = true ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8", "pem"], optional = true } -log = "0.4" +log.workspace = true p256 = { version = "0.13", features = ["ecdsa", "pkcs8"], optional = true } p384 = { version = "0.13", features = ["ecdsa", "pkcs8"], optional = true } p521 = { version = "0.13", features = ["ecdsa", "pkcs8"], optional = true } pem = "3" pkcs8 = { version = "0.10", features = ["alloc", "pem"] } rand_core = { version = "0.6", features = ["getrandom"] } -rsa = { version = "0.9", optional = true } +rsa = { workspace = true, optional = true } sha1 = "0.10" -sha2 = { version = "0.10", default-features = false, features = ["oid"] } +sha2.workspace = true thiserror = "2" -time = "0.3" -x509-cert = "0.2" +time.workspace = true +x509-cert.workspace = true [dev-dependencies] -env_logger = "0.11" +env_logger.workspace = true rustls = "0.23" diff --git a/README.md b/README.md index 8395edc..46a049c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ Add this to your `Cargo.toml`: certkit = "0.2" ``` +## CLI + +[`certkit-cli`](certkit-cli/) is a CLI that installs a `certkit` binary for generating keys and certificates. See its [README](certkit-cli/README.md) for usage. + ## Cargo features Each cryptographic algorithm is behind its own feature. All are enabled by default, so the default build is unchanged: diff --git a/certkit-cli/Cargo.toml b/certkit-cli/Cargo.toml new file mode 100644 index 0000000..f4abd58 --- /dev/null +++ b/certkit-cli/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "certkit-cli" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Command-line interface for certkit: generate keys and X.509 certificates." +repository.workspace = true +homepage.workspace = true +readme = "README.md" +keywords = ["x509", "certificate", "cli", "pki", "tls"] +categories = ["cryptography", "command-line-utilities"] +authors.workspace = true + +[[bin]] +name = "certkit" +path = "src/main.rs" + +[dependencies] +certkit = { path = "..", version = "0.2.0" } +anyhow = "1" +clap = { version = "4", features = ["derive"] } +const-oid.workspace = true +der.workspace = true +env_logger.workspace = true +log.workspace = true +rsa.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +time.workspace = true +x509-cert.workspace = true + +[dev-dependencies] +tempfile = "3" + diff --git a/certkit-cli/README.md b/certkit-cli/README.md new file mode 100644 index 0000000..cdffa1a --- /dev/null +++ b/certkit-cli/README.md @@ -0,0 +1,58 @@ +# certkit-cli + +A command-line interface for [`certkit`](https://crates.io/crates/certkit), the +pure-Rust X.509 toolkit. The installed binary is named `certkit`. + +## Install + +```sh +cargo install certkit-cli +``` + +The subcommand names and most arguments mirror [Botan's](https://botan.randombit.net/) +CLI (`keygen`, `gen_self_signed`, `issue`, `cert_info`), so existing Botan +muscle memory mostly transfers. + +## Usage + +Generate a private key (PKCS#8 PEM to stdout, or a file with `--out`): + +```sh +certkit keygen --algo Ed25519 --out key.pem +certkit keygen --algo RSA --params 3072 --out key.pem +``` + +Create a self-signed certificate (generates a key unless `--key` is given). The +common name is positional, as in Botan: + +```sh +certkit gen_self_signed example.com \ + --dns example.com --dns www.example.com \ + --email admin@example.com \ + --eku server-auth \ + --days 365 \ + --key-out key.pem --out cert.pem +``` + +Create a self-signed CA, then issue a leaf certificate from it: + +```sh +certkit gen_self_signed "Example CA" --ca \ + --key-out ca.key.pem --out ca.cert.pem + +certkit issue server.example.com \ + --ca-cert ca.cert.pem --ca-key ca.key.pem \ + --dns server.example.com --eku server-auth \ + --key-out server.key.pem --out server.cert.pem +``` + +Inspect a certificate (PEM or DER, auto-detected; reads stdin with `-`): + +```sh +certkit cert_info cert.pem +certkit cert_info cert.pem --fingerprint # add the SHA-256 fingerprint +certkit cert_info cert.der --json # machine-readable output +cat cert.pem | certkit cert_info - +``` + +Run `certkit --help` for the full set of options. diff --git a/certkit-cli/src/args.rs b/certkit-cli/src/args.rs new file mode 100644 index 0000000..bb2483d --- /dev/null +++ b/certkit-cli/src/args.rs @@ -0,0 +1,145 @@ +use std::path::PathBuf; + +use clap::{Args, ValueEnum}; + +use certkit::cert::extensions::ExtendedKeyUsageOption; + +/// Named to match Botan's `keygen --algo`. +/// Key shape (RSA size, ECDSA curve) is selected with `--params`. +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum Algorithm { + #[value(name = "RSA")] + Rsa, + #[value(name = "ECDSA")] + Ecdsa, + #[value(name = "Ed25519")] + Ed25519, +} + +/// A bare number is RSA bits, anything else is an ECDSA curve name. +#[derive(Copy, Clone, Debug)] +pub enum KeyParams { + Bits(u32), + Curve(Curve), +} + +#[derive(Copy, Clone, Debug)] +pub enum Curve { + P256, + P384, + P521, +} + +impl std::str::FromStr for KeyParams { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + if let Ok(bits) = s.parse::() { + return Ok(KeyParams::Bits(bits)); + } + let curve = match s.to_ascii_lowercase().as_str() { + "secp256r1" | "prime256v1" | "p256" | "p-256" => Curve::P256, + "secp384r1" | "p384" | "p-384" => Curve::P384, + "secp521r1" | "p521" | "p-521" => Curve::P521, + _ => { + return Err(format!( + "expected an RSA key size or an ECDSA curve \ + (secp256r1, secp384r1, secp521r1); got '{s}'" + )); + } + }; + Ok(KeyParams::Curve(curve)) + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +pub enum CertFormat { + Pem, + Der, +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum EkuOpt { + ServerAuth, + ClientAuth, + CodeSigning, + EmailProtection, + TimeStamping, + OcspSigning, +} + +impl From for ExtendedKeyUsageOption { + fn from(value: EkuOpt) -> Self { + match value { + EkuOpt::ServerAuth => ExtendedKeyUsageOption::ServerAuth, + EkuOpt::ClientAuth => ExtendedKeyUsageOption::ClientAuth, + EkuOpt::CodeSigning => ExtendedKeyUsageOption::CodeSigning, + EkuOpt::EmailProtection => ExtendedKeyUsageOption::EmailProtection, + EkuOpt::TimeStamping => ExtendedKeyUsageOption::TimeStamping, + EkuOpt::OcspSigning => ExtendedKeyUsageOption::OcspSigning, + } + } +} + +#[derive(Args)] +pub struct DnArgs { + /// Subject common name (CN), given positionally (as in Botan). + #[arg(value_name = "COMMON_NAME")] + pub common_name: String, + /// Subject country (C). + #[arg(long)] + pub country: Option, + /// Subject state or province (ST). + #[arg(long)] + pub state: Option, + /// Subject locality (L). + #[arg(long)] + pub locality: Option, + /// Subject organization (O). + #[arg(long)] + pub organization: Option, + /// Subject organizational unit (OU). + #[arg(long)] + pub organization_unit: Option, +} + +#[derive(Args)] +pub struct KeySourceArgs { + /// Algorithm for a freshly generated key (ignored when `--key` is given). + #[arg(short, long, alias = "algo", value_enum, ignore_case = true, default_value_t = Algorithm::Ecdsa)] + pub algorithm: Algorithm, + /// Key parameters: RSA bits (default 2048) or ECDSA curve (default secp256r1). Ignored for Ed25519 and when `--key` is given. + #[arg(long)] + pub params: Option, + /// Use an existing private key (PKCS#8 PEM) instead of generating one. + #[arg(long)] + pub key: Option, + /// Write a freshly generated private key here instead of stdout. + #[arg(long)] + pub key_out: Option, +} + +#[derive(Args)] +pub struct CertOptArgs { + /// DNS Subject Alternative Name (repeatable). + #[arg(long)] + pub dns: Vec, + /// Email (rfc822) Subject Alternative Name (repeatable). + #[arg(long)] + pub email: Vec, + /// Extended Key Usage purpose (repeatable). + #[arg(long = "eku", value_enum)] + pub eku: Vec, + /// Mark the certificate as a CA (Basic Constraints CA=true). + #[arg(long)] + pub ca: bool, + /// Validity period in days from now. + #[arg(long, default_value_t = 365)] + pub days: i64, + /// Certificate output encoding. + #[arg(long, value_enum, default_value_t = CertFormat::Pem)] + pub format: CertFormat, + /// Write the certificate here instead of stdout. + #[arg(short, long)] + pub out: Option, +} diff --git a/certkit-cli/src/cmd/inspect.rs b/certkit-cli/src/cmd/inspect.rs new file mode 100644 index 0000000..8afa17c --- /dev/null +++ b/certkit-cli/src/cmd/inspect.rs @@ -0,0 +1,42 @@ +use std::io::Write; +use std::path::PathBuf; + +use anyhow::Result; +use clap::Args; + +use certkit::cert::Certificate; + +use crate::io::read_cert_input; +use crate::report::CertReport; + +#[derive(Args)] +pub struct InspectOpt { + /// Certificate file to read (PEM or DER, auto-detected). Use `-` for stdin. + pub input: PathBuf, + /// Also print the SHA-256 fingerprint of the DER encoding. + #[arg(long)] + pub fingerprint: bool, + /// Emit machine-readable JSON instead of text. + #[arg(long)] + pub json: bool, +} + +impl InspectOpt { + pub fn execute(&self) -> Result<()> { + log::debug!("inspecting certificate from {}", self.input.display()); + + let bytes = read_cert_input(&self.input)?; + let cert = Certificate::from_bytes(&bytes)?; + let report = CertReport::from_cert(&cert, self.fingerprint)?; + + let out = if self.json { + report.to_json() + } else { + report.to_text() + }; + let mut stdout = std::io::stdout().lock(); + stdout.write_all(out.as_bytes())?; + stdout.flush()?; + Ok(()) + } +} diff --git a/certkit-cli/src/cmd/issue.rs b/certkit-cli/src/cmd/issue.rs new file mode 100644 index 0000000..2ff7047 --- /dev/null +++ b/certkit-cli/src/cmd/issue.rs @@ -0,0 +1,62 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::Result; +use clap::Args; + +use certkit::cert::CertificateWithPrivateKey; +use certkit::cert::params::Validity; +use certkit::issuer::Issuer; +use certkit::key::KeyPair; + +use crate::args::{CertOptArgs, DnArgs, KeySourceArgs}; +use crate::io::{emit, guard_stdout_clash, load_ca_cert}; +use crate::keys::key_pair; +use crate::params::cert_info; + +#[derive(Args)] +pub struct IssueOpt { + #[command(flatten)] + pub dn: DnArgs, + #[command(flatten)] + pub key: KeySourceArgs, + #[command(flatten)] + pub opts: CertOptArgs, + /// CA certificate to sign with (PEM or DER). + #[arg(long)] + pub ca_cert: PathBuf, + /// CA private key to sign with (PKCS#8 PEM). + #[arg(long)] + pub ca_key: PathBuf, +} + +impl IssueOpt { + pub fn execute(&self) -> Result<()> { + let (key, generated) = key_pair(&self.key)?; + guard_stdout_clash( + generated, + &self.key.key_out, + &self.opts.out, + self.opts.format, + )?; + + log::debug!( + "loading CA certificate from {} and key from {}", + self.ca_cert.display(), + self.ca_key.display() + ); + let ca_cert = load_ca_cert(&self.ca_cert)?; + let ca_key = KeyPair::import_from_pkcs8_pem(&fs::read_to_string(&self.ca_key)?)?; + let ca = CertificateWithPrivateKey::new(ca_cert, ca_key); + + let cert_info = cert_info(&self.dn, &key, &self.opts)?; + let cert = ca.issue(&cert_info, Validity::for_days(self.opts.days)?)?; + + emit( + &cert, + generated.then_some(&key), + &self.key.key_out, + &self.opts, + ) + } +} diff --git a/certkit-cli/src/cmd/keygen.rs b/certkit-cli/src/cmd/keygen.rs new file mode 100644 index 0000000..58cc7ee --- /dev/null +++ b/certkit-cli/src/cmd/keygen.rs @@ -0,0 +1,33 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::Args; + +use crate::args::{Algorithm, KeyParams}; +use crate::io::write_bytes; +use crate::keys::generate; + +#[derive(Args)] +pub struct KeygenOpt { + /// Key algorithm (RSA, ECDSA, or Ed25519). + #[arg(short, long, alias = "algo", value_enum, ignore_case = true, default_value_t = Algorithm::Ecdsa)] + pub algorithm: Algorithm, + /// Key parameters: RSA bits (default 2048) or ECDSA curve (default secp256r1). Ignored for Ed25519. + #[arg(long)] + pub params: Option, + /// Write the key here instead of stdout. + #[arg(short, long)] + pub out: Option, +} + +impl KeygenOpt { + pub fn execute(&self) -> Result<()> { + let key = generate(self.algorithm, self.params)?; + let pem = key.encode_private_key_pem()?; + write_bytes(&self.out, pem.as_bytes())?; + if let Some(path) = &self.out { + log::info!("wrote private key to {}", path.display()); + } + Ok(()) + } +} diff --git a/certkit-cli/src/cmd/mod.rs b/certkit-cli/src/cmd/mod.rs new file mode 100644 index 0000000..45c0a98 --- /dev/null +++ b/certkit-cli/src/cmd/mod.rs @@ -0,0 +1,4 @@ +pub mod inspect; +pub mod issue; +pub mod keygen; +pub mod self_signed; diff --git a/certkit-cli/src/cmd/self_signed.rs b/certkit-cli/src/cmd/self_signed.rs new file mode 100644 index 0000000..e3f2d24 --- /dev/null +++ b/certkit-cli/src/cmd/self_signed.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use clap::Args; +use time::{Duration, OffsetDateTime}; + +use certkit::cert::Certificate; + +use crate::args::{CertOptArgs, DnArgs, KeySourceArgs}; +use crate::io::{emit, guard_stdout_clash}; +use crate::keys::key_pair; +use crate::params::cert_info; + +#[derive(Args)] +pub struct SelfSignedOpt { + #[command(flatten)] + pub dn: DnArgs, + #[command(flatten)] + pub key: KeySourceArgs, + #[command(flatten)] + pub opts: CertOptArgs, +} + +impl SelfSignedOpt { + pub fn execute(&self) -> Result<()> { + let (key, generated) = key_pair(&self.key)?; + guard_stdout_clash( + generated, + &self.key.key_out, + &self.opts.out, + self.opts.format, + )?; + + let cert_info = cert_info(&self.dn, &key, &self.opts)?; + let now = OffsetDateTime::now_utc(); + let cert = Certificate::new_self_signed_with_expiration( + &cert_info, + &key, + now, + now + Duration::days(self.opts.days), + )?; + + emit( + &cert, + generated.then_some(&key), + &self.key.key_out, + &self.opts, + ) + } +} diff --git a/certkit-cli/src/io.rs b/certkit-cli/src/io.rs new file mode 100644 index 0000000..9a6a943 --- /dev/null +++ b/certkit-cli/src/io.rs @@ -0,0 +1,75 @@ +use std::fs; +use std::io::{Read, Write}; +use std::path::PathBuf; + +use anyhow::{Result, bail}; +use certkit::cert::Certificate; +use certkit::key::KeyPair; + +use crate::args::{CertFormat, CertOptArgs}; + +pub fn read_cert_input(path: &std::path::Path) -> Result> { + if path.as_os_str() == "-" { + let mut buf = Vec::new(); + std::io::stdin().lock().read_to_end(&mut buf)?; + Ok(buf) + } else { + Ok(fs::read(path)?) + } +} + +pub fn emit( + cert: &Certificate, + key: Option<&KeyPair>, + key_out: &Option, + opts: &CertOptArgs, +) -> Result<()> { + if let Some(key) = key { + let pem = key.encode_private_key_pem()?; + write_bytes(key_out, pem.as_bytes())?; + if let Some(path) = key_out { + log::info!("wrote private key to {}", path.display()); + } + } + + let bytes = match opts.format { + CertFormat::Pem => cert.to_pem()?.into_bytes(), + CertFormat::Der => cert.to_der()?, + }; + write_bytes(&opts.out, &bytes)?; + if let Some(path) = &opts.out { + log::info!("wrote certificate to {}", path.display()); + } + Ok(()) +} + +/// Rejects binary DER cert + PEM key both going to stdout (would corrupt output). +pub fn guard_stdout_clash( + generated: bool, + key_out: &Option, + cert_out: &Option, + format: CertFormat, +) -> Result<()> { + if generated && key_out.is_none() && cert_out.is_none() && format == CertFormat::Der { + bail!( + "refusing to write a binary DER certificate and a PEM private key both to stdout; pass --out and/or --key-out" + ); + } + Ok(()) +} + +pub fn load_ca_cert(path: &std::path::Path) -> Result { + Ok(Certificate::from_bytes(&fs::read(path)?)?) +} + +pub fn write_bytes(out: &Option, bytes: &[u8]) -> Result<()> { + match out { + Some(path) => fs::write(path, bytes)?, + None => { + let mut stdout = std::io::stdout().lock(); + stdout.write_all(bytes)?; + stdout.flush()?; + } + } + Ok(()) +} diff --git a/certkit-cli/src/keys.rs b/certkit-cli/src/keys.rs new file mode 100644 index 0000000..d111339 --- /dev/null +++ b/certkit-cli/src/keys.rs @@ -0,0 +1,47 @@ +use std::fs; + +use anyhow::{Result, bail}; +use certkit::key::KeyPair; + +use crate::args::{Algorithm, Curve, KeyParams, KeySourceArgs}; + +pub fn generate(algorithm: Algorithm, params: Option) -> Result { + match algorithm { + Algorithm::Rsa => { + let bits = match params { + Some(KeyParams::Bits(bits)) => bits as usize, + Some(KeyParams::Curve(_)) => { + bail!("RSA takes a key size, not a curve; e.g. --params 2048"); + } + None => 2048, + }; + Ok(KeyPair::generate_rsa(bits)?) + } + Algorithm::Ecdsa => { + let curve = match params { + Some(KeyParams::Curve(curve)) => curve, + Some(KeyParams::Bits(_)) => { + bail!("ECDSA takes a curve, not a key size; e.g. --params secp256r1"); + } + None => Curve::P256, + }; + Ok(match curve { + Curve::P256 => KeyPair::generate_ecdsa_p256(), + Curve::P384 => KeyPair::generate_ecdsa_p384(), + Curve::P521 => KeyPair::generate_ecdsa_p521(), + }) + } + Algorithm::Ed25519 => Ok(KeyPair::generate_ed25519()), + } +} + +/// Returns `(key, generated)` where `generated` is true when the key was freshly created. +pub fn key_pair(args: &KeySourceArgs) -> Result<(KeyPair, bool)> { + match &args.key { + Some(path) => Ok(( + KeyPair::import_from_pkcs8_pem(&fs::read_to_string(path)?)?, + false, + )), + None => Ok((generate(args.algorithm, args.params)?, true)), + } +} diff --git a/certkit-cli/src/main.rs b/certkit-cli/src/main.rs new file mode 100644 index 0000000..4ecae90 --- /dev/null +++ b/certkit-cli/src/main.rs @@ -0,0 +1,64 @@ +mod args; +mod cmd; +mod io; +mod keys; +mod params; +mod report; + +use std::process; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +use cmd::inspect::InspectOpt; +use cmd::issue::IssueOpt; +use cmd::keygen::KeygenOpt; +use cmd::self_signed::SelfSignedOpt; + +#[derive(Parser)] +#[command( + name = "certkit", + version, + about = "Generate keys and X.509 certificates with certkit" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Generate a new private key (PKCS#8 PEM). + #[command(name = "keygen")] + GenerateKey(KeygenOpt), + /// Create a self-signed certificate. + #[command(name = "gen_self_signed")] + SelfSigned(SelfSignedOpt), + /// Issue a certificate signed by an existing CA. + #[command(name = "issue")] + Issue(IssueOpt), + /// Parse a certificate and print its fields. + #[command(name = "cert_info")] + Inspect(InspectOpt), +} + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp(None) + .init(); + + let cli = Cli::parse(); + if let Err(err) = run(cli) { + eprintln!("error: {err}"); + process::exit(1); + } +} + +fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::GenerateKey(opt) => opt.execute(), + Command::SelfSigned(opt) => opt.execute(), + Command::Issue(opt) => opt.execute(), + Command::Inspect(opt) => opt.execute(), + } +} diff --git a/certkit-cli/src/params.rs b/certkit-cli/src/params.rs new file mode 100644 index 0000000..ab8eacb --- /dev/null +++ b/certkit-cli/src/params.rs @@ -0,0 +1,64 @@ +use anyhow::{Result, anyhow}; +use der::Encode; +use der::asn1::Ia5String; +use x509_cert::ext::pkix; +use x509_cert::ext::pkix::name::GeneralName; + +use certkit::cert::extensions::{ExtendedKeyUsageOption, SubjectAltName, ToAndFromX509Extension}; +use certkit::cert::params::{CertificateParams, DistinguishedName, ExtensionParam}; +use certkit::key::{KeyPair, PublicKey}; + +use crate::args::{CertOptArgs, DnArgs}; + +pub fn cert_info(dn: &DnArgs, key: &KeyPair, opts: &CertOptArgs) -> Result { + let subject = DistinguishedName::builder() + .common_name(dn.common_name.clone()) + .maybe_country(dn.country.clone()) + .maybe_state(dn.state.clone()) + .maybe_locality(dn.locality.clone()) + .maybe_organization(dn.organization.clone()) + .maybe_organization_unit(dn.organization_unit.clone()) + .build(); + + let usages: Vec = opts.eku.iter().map(|e| (*e).into()).collect(); + + let mut extensions = Vec::new(); + if let Some(san) = build_san(&opts.dns, &opts.email)? { + extensions.push(san); + } + + Ok(CertificateParams::builder() + .subject(subject) + .subject_public_key(PublicKey::from_key_pair(key)) + .is_ca(opts.ca) + .usages(usages) + .extensions(extensions) + .build()) +} + +/// certkit's `SubjectAltName` only models DNS names, so we assemble the extension +/// directly from x509_cert GeneralNames to also carry rfc822 (email) entries. +fn build_san(dns: &[String], email: &[String]) -> Result> { + if dns.is_empty() && email.is_empty() { + return Ok(None); + } + + let mut names = Vec::new(); + for name in dns { + let ia5 = + Ia5String::try_from(name.clone()).map_err(|_| anyhow!("invalid DNS name: {name}"))?; + names.push(GeneralName::DnsName(ia5)); + } + for addr in email { + let ia5 = Ia5String::try_from(addr.clone()) + .map_err(|_| anyhow!("invalid email address: {addr}"))?; + names.push(GeneralName::Rfc822Name(ia5)); + } + + let san = pkix::SubjectAltName(names); + Ok(Some(ExtensionParam { + oid: SubjectAltName::OID, + critical: false, + value: san.to_der()?, + })) +} diff --git a/certkit-cli/src/report/extensions.rs b/certkit-cli/src/report/extensions.rs new file mode 100644 index 0000000..f064b7b --- /dev/null +++ b/certkit-cli/src/report/extensions.rs @@ -0,0 +1,119 @@ +use der::Decode; +use der::oid::AssociatedOid; +use x509_cert::ext::pkix; +use x509_cert::ext::pkix::name::GeneralName; + +use super::ExtReport; +use super::fmt::{UNDECODABLE, describe_oid, format_ip, hex_colons, join_or_none}; + +/// Each arm matches via `AssociatedOid::OID` so the OID and its decoder stay in +/// sync. `ObjectIdentifier` isn't usable in match patterns, hence the if/else chain. +pub(super) fn describe_extension(ext: &x509_cert::ext::Extension) -> ExtReport { + let value = ext.extn_value.as_bytes(); + let oid = ext.extn_id; + let (name, summary) = if oid == pkix::BasicConstraints::OID { + ("Basic Constraints", basic_constraints_summary(value)) + } else if oid == pkix::KeyUsage::OID { + ("Key Usage", key_usage_summary(value)) + } else if oid == pkix::ExtendedKeyUsage::OID { + ("Extended Key Usage", extended_key_usage_summary(value)) + } else if oid == pkix::SubjectAltName::OID { + ("Subject Alternative Name", san_summary(value)) + } else if oid == pkix::SubjectKeyIdentifier::OID { + ("Subject Key Identifier", ski_summary(value)) + } else if oid == pkix::AuthorityKeyIdentifier::OID { + ("Authority Key Identifier", aki_summary(value)) + } else { + ("", format!("{} bytes", value.len())) + }; + ExtReport { + oid: oid.to_string(), + name, + critical: ext.critical, + summary, + } +} + +fn basic_constraints_summary(value: &[u8]) -> String { + match pkix::BasicConstraints::from_der(value) { + Ok(bc) => match bc.path_len_constraint { + Some(len) => format!("CA={}, pathLen={len}", bc.ca), + None => format!("CA={}", bc.ca), + }, + Err(_) => UNDECODABLE.to_string(), + } +} + +fn key_usage_summary(value: &[u8]) -> String { + match pkix::KeyUsage::from_der(value) { + Ok(ku) => { + let names: Vec<&str> = ku.0.into_iter().map(key_usage_name).collect(); + join_or_none(&names) + } + Err(_) => UNDECODABLE.to_string(), + } +} + +fn extended_key_usage_summary(value: &[u8]) -> String { + match pkix::ExtendedKeyUsage::from_der(value) { + Ok(eku) => { + let names: Vec = eku.0.iter().map(|oid| describe_oid(*oid)).collect(); + join_or_none(&names) + } + Err(_) => UNDECODABLE.to_string(), + } +} + +fn san_summary(value: &[u8]) -> String { + match pkix::SubjectAltName::from_der(value) { + Ok(san) => { + let names: Vec = san.0.iter().map(general_name).collect(); + join_or_none(&names) + } + Err(_) => UNDECODABLE.to_string(), + } +} + +fn ski_summary(value: &[u8]) -> String { + match pkix::SubjectKeyIdentifier::from_der(value) { + Ok(ski) => hex_colons(ski.0.as_bytes()), + Err(_) => UNDECODABLE.to_string(), + } +} + +fn aki_summary(value: &[u8]) -> String { + match pkix::AuthorityKeyIdentifier::from_der(value) { + Ok(aki) => match aki.key_identifier { + Some(id) => format!("keyid:{}", hex_colons(id.as_bytes())), + None => "(no key identifier)".to_string(), + }, + Err(_) => UNDECODABLE.to_string(), + } +} + +fn general_name(name: &GeneralName) -> String { + match name { + GeneralName::DnsName(s) => format!("DNS:{s}"), + GeneralName::Rfc822Name(s) => format!("email:{s}"), + GeneralName::UniformResourceIdentifier(s) => format!("URI:{s}"), + GeneralName::IpAddress(octets) => format!("IP:{}", format_ip(octets.as_bytes())), + GeneralName::DirectoryName(name) => format!("dirName:{name}"), + GeneralName::RegisteredId(oid) => format!("registeredID:{oid}"), + other => format!("{other:?}"), + } +} + +fn key_usage_name(usage: pkix::KeyUsages) -> &'static str { + use pkix::KeyUsages; + match usage { + KeyUsages::DigitalSignature => "digitalSignature", + KeyUsages::NonRepudiation => "nonRepudiation", + KeyUsages::KeyEncipherment => "keyEncipherment", + KeyUsages::DataEncipherment => "dataEncipherment", + KeyUsages::KeyAgreement => "keyAgreement", + KeyUsages::KeyCertSign => "keyCertSign", + KeyUsages::CRLSign => "cRLSign", + KeyUsages::EncipherOnly => "encipherOnly", + KeyUsages::DecipherOnly => "decipherOnly", + } +} diff --git a/certkit-cli/src/report/fmt.rs b/certkit-cli/src/report/fmt.rs new file mode 100644 index 0000000..c5b731d --- /dev/null +++ b/certkit-cli/src/report/fmt.rs @@ -0,0 +1,79 @@ +use const_oid::ObjectIdentifier; +use const_oid::db::{rfc5280, rfc5912, rfc8410}; + +pub(super) const UNDECODABLE: &str = "(undecodable)"; + +pub(super) fn hex_colons(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") +} + +pub(super) fn format_ip(octets: &[u8]) -> String { + match octets.len() { + 4 => octets + .iter() + .map(u8::to_string) + .collect::>() + .join("."), + 16 => octets + .chunks(2) + .map(|pair| format!("{:02x}{:02x}", pair[0], pair[1])) + .collect::>() + .join(":"), + _ => hex_colons(octets), + } +} + +/// OID-to-name mapping. Uses const-oid named constants so OID values and their +/// decoders can never drift apart. Falls back to const-oid's DB, then bare OID. +pub(super) fn describe_oid(oid: ObjectIdentifier) -> String { + let name = if oid == rfc5280::ID_KP_SERVER_AUTH { + "serverAuth" + } else if oid == rfc5280::ID_KP_CLIENT_AUTH { + "clientAuth" + } else if oid == rfc5280::ID_KP_CODE_SIGNING { + "codeSigning" + } else if oid == rfc5280::ID_KP_EMAIL_PROTECTION { + "emailProtection" + } else if oid == rfc5280::ID_KP_TIME_STAMPING { + "timeStamping" + } else if oid == rfc5280::ID_KP_OCSP_SIGNING { + "OCSPSigning" + } else if oid == rfc5912::ECDSA_WITH_SHA_256 { + "ecdsa-with-SHA256" + } else if oid == rfc5912::ECDSA_WITH_SHA_384 { + "ecdsa-with-SHA384" + } else if oid == rfc5912::ECDSA_WITH_SHA_512 { + "ecdsa-with-SHA512" + } else if oid == rfc5912::SHA_1_WITH_RSA_ENCRYPTION { + "sha1WithRSAEncryption" + } else if oid == rfc5912::SHA_256_WITH_RSA_ENCRYPTION { + "sha256WithRSAEncryption" + } else if oid == rfc5912::SHA_384_WITH_RSA_ENCRYPTION { + "sha384WithRSAEncryption" + } else if oid == rfc5912::SHA_512_WITH_RSA_ENCRYPTION { + "sha512WithRSAEncryption" + } else if oid == rfc8410::ID_ED_25519 { + "Ed25519" + } else { + return const_oid::db::DB + .by_oid(&oid) + .map_or_else(|| oid.to_string(), str::to_string); + }; + name.to_string() +} + +pub(super) fn join_or_none>(parts: &[S]) -> String { + if parts.is_empty() { + "(none)".to_string() + } else { + parts + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(", ") + } +} diff --git a/certkit-cli/src/report/mod.rs b/certkit-cli/src/report/mod.rs new file mode 100644 index 0000000..3b81dc3 --- /dev/null +++ b/certkit-cli/src/report/mod.rs @@ -0,0 +1,142 @@ +mod extensions; +mod fmt; + +use anyhow::Result; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; +use x509_cert::Certificate as X509Certificate; + +use certkit::cert::Certificate; +use certkit::key::PublicKey; + +use extensions::describe_extension; +use fmt::{describe_oid, hex_colons}; + +#[derive(Serialize)] +struct ExtReport { + oid: String, + name: &'static str, + critical: bool, + summary: String, +} + +#[derive(Serialize)] +pub struct CertReport { + subject: String, + issuer: String, + serial: String, + not_before: String, + not_after: String, + expired: bool, + not_yet_valid: bool, + days_remaining: i64, + public_key: String, + signature_algorithm: String, + fingerprint_sha256: Option, + extensions: Vec, +} + +impl CertReport { + pub fn from_cert(cert: &Certificate, want_fingerprint: bool) -> Result { + use der::Decode; + let der_bytes = cert.to_der()?; + let raw = X509Certificate::from_der(&der_bytes)?; + let tbs = &raw.tbs_certificate; + + let not_before = tbs.validity.not_before.to_unix_duration().as_secs() as i64; + let not_after = tbs.validity.not_after.to_unix_duration().as_secs() as i64; + let now = OffsetDateTime::now_utc().unix_timestamp(); + + let fingerprint_sha256 = want_fingerprint.then(|| hex_colons(&Sha256::digest(&der_bytes))); + + let extensions = tbs + .extensions + .as_deref() + .unwrap_or(&[]) + .iter() + .map(describe_extension) + .collect(); + + Ok(Self { + subject: tbs.subject.to_string(), + issuer: tbs.issuer.to_string(), + serial: hex_colons(tbs.serial_number.as_bytes()), + not_before: tbs.validity.not_before.to_string(), + not_after: tbs.validity.not_after.to_string(), + expired: not_after < now, + not_yet_valid: not_before > now, + days_remaining: (not_after - now).div_euclid(86_400), + public_key: describe_public_key(&tbs.subject_public_key_info), + signature_algorithm: describe_oid(raw.signature_algorithm.oid), + fingerprint_sha256, + extensions, + }) + } + + fn validity_note(&self) -> String { + if self.expired { + "(expired)".to_string() + } else if self.not_yet_valid { + "(not yet valid)".to_string() + } else { + format!("(expires in {} days)", self.days_remaining) + } + } + + pub fn to_text(&self) -> String { + let mut out = String::new(); + let mut field = |label: &str, value: &str| { + out.push_str(&format!("{label:<22}{value}\n")); + }; + field("Subject:", &self.subject); + field("Issuer:", &self.issuer); + field("Serial:", &self.serial); + field("Not Before:", &self.not_before); + field( + "Not After:", + &format!("{} {}", self.not_after, self.validity_note()), + ); + field("Public Key:", &self.public_key); + field("Signature Algorithm:", &self.signature_algorithm); + if let Some(fp) = &self.fingerprint_sha256 { + field("SHA-256 Fingerprint:", fp); + } + + if self.extensions.is_empty() { + out.push_str("Extensions: (none)\n"); + } else { + out.push_str("Extensions:\n"); + for ext in &self.extensions { + let label = if ext.name.is_empty() { + ext.oid.clone() + } else { + ext.name.to_string() + }; + let crit = if ext.critical { " (critical)" } else { "" }; + out.push_str(&format!(" {label}{crit}: {}\n", ext.summary)); + } + } + out + } + + pub fn to_json(&self) -> String { + let mut out = serde_json::to_string(self).expect("CertReport is always serializable"); + out.push('\n'); + out + } +} + +fn describe_public_key(spki: &x509_cert::spki::SubjectPublicKeyInfoOwned) -> String { + match PublicKey::from_x509spki(spki) { + Ok(PublicKey::Rsa(key)) => { + use rsa::traits::PublicKeyParts; + format!("RSA ({} bit)", key.n().bits()) + } + Ok(PublicKey::EcdsaP256(_)) => "ECDSA (P-256)".to_string(), + Ok(PublicKey::EcdsaP384(_)) => "ECDSA (P-384)".to_string(), + Ok(PublicKey::EcdsaP521(_)) => "ECDSA (P-521)".to_string(), + Ok(PublicKey::Ed25519(_)) => "Ed25519".to_string(), + Err(_) => format!("unrecognized (OID {})", spki.algorithm.oid), + } +} diff --git a/certkit-cli/tests/chain.rs b/certkit-cli/tests/chain.rs new file mode 100644 index 0000000..f124571 --- /dev/null +++ b/certkit-cli/tests/chain.rs @@ -0,0 +1,104 @@ +//! Validates `certkit`-generated certificate chains with the system `openssl` +//! and `botan` CLIs. +//! +//! For each case we build a full `root -> intermediate -> leaf` chain with the +//! `certkit` binary and then have both tools verify the whole chain. A chain +//! verification exercises every signature in the path plus issuer linking +//! (Authority/Subject Key Identifiers), so it subsumes per-certificate parsing +//! and field checks. The mixed-algorithm case additionally proves a CA of one +//! algorithm can sign a subject of another. + +pub mod common; + +use std::process::Command; + +use common::{Chain, build_chain, have_tool}; +use tempfile::tempdir; + +/// Verifies the chain with `openssl verify` (root trusted, intermediate supplied +/// as an untrusted intermediate). A non-zero exit means a signature or path +/// failure. +fn openssl_verify(chain: &Chain) { + let output = Command::new("openssl") + .arg("verify") + .arg("-CAfile") + .arg(&chain.root) + .arg("-untrusted") + .arg(&chain.intermediate) + .arg(&chain.leaf) + .output() + .expect("failed to run openssl verify"); + assert!( + output.status.success(), + "openssl failed to verify the chain:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +/// Verifies the chain with `botan cert_verify`. botan exits 0 even on failure, +/// so success is asserted on its stdout instead of the exit status. +fn botan_verify(chain: &Chain) { + let output = Command::new("botan") + .arg("cert_verify") + .arg(&chain.leaf) + .arg(&chain.intermediate) + .arg(&chain.root) + .output() + .expect("failed to run botan cert_verify"); + let text = String::from_utf8_lossy(&output.stdout); + assert!( + text.contains("passes validation checks"), + "botan failed to verify the chain:\n{text}" + ); +} + +/// Builds a chain with the given per-level algorithms and verifies it with every +/// available tool. A missing tool is skipped so local runs stay green; CI +/// installs both so the checks actually execute there. +fn assert_chain_validates(root_alg: &str, intermediate_alg: &str, leaf_alg: &str) { + let dir = tempdir().unwrap(); + let chain = build_chain(dir.path(), root_alg, intermediate_alg, leaf_alg); + + if have_tool("openssl") { + openssl_verify(&chain); + } else { + eprintln!("skipping openssl verification: openssl CLI not found"); + } + + if have_tool("botan") { + botan_verify(&chain); + } else { + eprintln!("skipping botan verification: botan CLI not found"); + } +} + +#[test] +fn chain_p256() { + assert_chain_validates("p256", "p256", "p256"); +} + +#[test] +fn chain_p384() { + assert_chain_validates("p384", "p384", "p384"); +} + +#[test] +fn chain_p521() { + assert_chain_validates("p521", "p521", "p521"); +} + +#[test] +fn chain_ed25519() { + assert_chain_validates("ed25519", "ed25519", "ed25519"); +} + +#[test] +fn chain_rsa() { + assert_chain_validates("rsa", "rsa", "rsa"); +} + +#[test] +fn chain_mixed_algorithms() { + assert_chain_validates("rsa", "p256", "ed25519"); +} diff --git a/certkit-cli/tests/common.rs b/certkit-cli/tests/common.rs new file mode 100644 index 0000000..2987485 --- /dev/null +++ b/certkit-cli/tests/common.rs @@ -0,0 +1,111 @@ +//! Shared helpers for the CLI integration tests. +//! +//! These tests drive the built `certkit` binary to produce certificate chains +//! and then validate them with the system `openssl` and `botan` command-line +//! tools. Cargo exposes the binary path through `CARGO_BIN_EXE_certkit`, so no +//! extra dependency is needed to locate it. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// A `Command` for the `certkit` binary under test. +pub fn certkit() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_certkit")); + if std::env::var_os("RUST_LOG").is_none() { + cmd.env("RUST_LOG", "error"); + } + cmd +} + +/// Returns `true` when an external CLI tool is installed and runnable. +/// +/// Both `openssl version` and `botan version` exit successfully, so this is a +/// cheap presence check. Tests skip (rather than fail) when the validator is +/// missing, which keeps local runs green on machines without botan installed; +/// CI installs both tools so the checks actually execute there. +pub fn have_tool(tool: &str) -> bool { + Command::new(tool) + .arg("version") + .output() + .map(|out| out.status.success()) + .unwrap_or(false) +} + +/// Paths to the PEM certificates of a generated chain. +pub struct Chain { + pub root: PathBuf, + pub intermediate: PathBuf, + pub leaf: PathBuf, +} + +/// Builds a `root -> intermediate -> leaf` certificate chain with the `certkit` +/// binary, writing all files under `dir`. Each level uses the given algorithm, +/// so callers can exercise a single algorithm or a mixed chain. +pub fn build_chain(dir: &Path, root_alg: &str, intermediate_alg: &str, leaf_alg: &str) -> Chain { + let root = dir.join("root.pem"); + let root_key = dir.join("root.key"); + let intermediate = dir.join("intermediate.pem"); + let intermediate_key = dir.join("intermediate.key"); + let leaf = dir.join("leaf.pem"); + let leaf_key = dir.join("leaf.key"); + + // Self-signed root CA. + run(certkit() + .args(["gen_self_signed", "Test Root CA", "--ca"]) + .args(algo_args(root_alg)) + .arg("--key-out") + .arg(&root_key) + .arg("--out") + .arg(&root)); + + // Intermediate CA, signed by the root. + run(certkit() + .args(["issue", "Test Intermediate CA", "--ca"]) + .args(algo_args(intermediate_alg)) + .arg("--ca-cert") + .arg(&root) + .arg("--ca-key") + .arg(&root_key) + .arg("--key-out") + .arg(&intermediate_key) + .arg("--out") + .arg(&intermediate)); + + // Leaf, signed by the intermediate. + run(certkit() + .args(["issue", "leaf.example.com", "--eku", "server-auth"]) + .args(algo_args(leaf_alg)) + .arg("--ca-cert") + .arg(&intermediate) + .arg("--ca-key") + .arg(&intermediate_key) + .arg("--key-out") + .arg(&leaf_key) + .arg("--out") + .arg(&leaf)); + + Chain { + root, + intermediate, + leaf, + } +} + +/// Translates a per-level algorithm label into the Botan-style `--algorithm` +/// (and `--params`) arguments certkit now expects. +fn algo_args(alg: &str) -> Vec<&'static str> { + match alg { + "p256" => vec!["--algorithm", "ECDSA", "--params", "secp256r1"], + "p384" => vec!["--algorithm", "ECDSA", "--params", "secp384r1"], + "p521" => vec!["--algorithm", "ECDSA", "--params", "secp521r1"], + "ed25519" => vec!["--algorithm", "Ed25519"], + "rsa" => vec!["--algorithm", "RSA"], + other => panic!("unknown algorithm label: {other}"), + } +} + +/// Runs a `certkit` command and asserts it succeeded. +fn run(command: &mut Command) { + let status = command.status().expect("failed to run certkit"); + assert!(status.success(), "certkit command failed: {command:?}"); +} diff --git a/certkit-cli/tests/inspect.rs b/certkit-cli/tests/inspect.rs new file mode 100644 index 0000000..363b01a --- /dev/null +++ b/certkit-cli/tests/inspect.rs @@ -0,0 +1,191 @@ +//! Drives the built `certkit` binary to produce certificates and checks that +//! `certkit cert_info` reports their fields. The fingerprint is cross-checked +//! against `botan` when it is installed, so the two tools must agree. + +pub mod common; + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +use common::certkit; +use tempfile::tempdir; + +/// Writes a self-signed P-256 leaf (with SANs and EKUs) to `dir`, returning its path. +fn write_leaf(dir: &Path) -> std::path::PathBuf { + let cert = dir.join("leaf.pem"); + let key = dir.join("leaf.key"); + let status = certkit() + .args([ + "gen_self_signed", + "leaf.example.com", + "--dns", + "leaf.example.com", + "--dns", + "www.example.com", + "--email", + "admin@example.com", + "--eku", + "server-auth", + "--eku", + "client-auth", + "--days", + "30", + "--algorithm", + "ECDSA", + "--params", + "secp256r1", + ]) + .arg("--key-out") + .arg(&key) + .arg("--out") + .arg(&cert) + .status() + .expect("failed to run certkit gen_self_signed"); + assert!(status.success(), "certkit gen_self_signed failed"); + cert +} + +#[test] +fn inspect_reports_core_fields() { + let dir = tempdir().unwrap(); + let cert = write_leaf(dir.path()); + + let out = certkit().arg("cert_info").arg(&cert).output().unwrap(); + assert!( + out.status.success(), + "cert_info failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let text = String::from_utf8_lossy(&out.stdout); + + assert!(text.contains("CN=leaf.example.com"), "subject CN: {text}"); + assert!(text.contains("ECDSA (P-256)"), "public key: {text}"); + assert!(text.contains("ecdsa-with-SHA256"), "sig alg: {text}"); + assert!( + text.contains("(expires in 30 days)"), + "validity note: {text}" + ); + assert!( + text.contains("Subject Alternative Name"), + "SAN label: {text}" + ); + assert!(text.contains("DNS:www.example.com"), "SAN value: {text}"); + assert!( + text.contains("email:admin@example.com"), + "email SAN: {text}" + ); + assert!(text.contains("Extended Key Usage"), "EKU label: {text}"); + assert!(text.contains("serverAuth, clientAuth"), "EKU value: {text}"); + assert!(text.contains("Basic Constraints"), "BC label: {text}"); + assert!(text.contains("CA=false"), "BC value: {text}"); +} + +#[test] +fn inspect_ca_reports_cert_sign() { + let dir = tempdir().unwrap(); + let cert = dir.path().join("ca.pem"); + let key = dir.path().join("ca.key"); + let status = certkit() + .args([ + "gen_self_signed", + "Example Root CA", + "--ca", + "--algorithm", + "RSA", + "--params", + "2048", + ]) + .arg("--key-out") + .arg(&key) + .arg("--out") + .arg(&cert) + .status() + .expect("failed to run certkit gen_self_signed"); + assert!(status.success()); + + let out = certkit().arg("cert_info").arg(&cert).output().unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("RSA (2048 bit)"), "rsa size: {text}"); + assert!(text.contains("CA=true"), "BC value: {text}"); + assert!(text.contains("keyCertSign"), "key usage: {text}"); +} + +#[test] +fn inspect_json_and_fingerprint() { + let dir = tempdir().unwrap(); + let cert = write_leaf(dir.path()); + + let out = certkit() + .arg("cert_info") + .arg(&cert) + .args(["--json", "--fingerprint"]) + .output() + .unwrap(); + assert!(out.status.success()); + let text = String::from_utf8_lossy(&out.stdout); + + assert!(text.trim_start().starts_with('{'), "json object: {text}"); + assert!(text.contains("\"subject\":\"CN=leaf.example.com"), "{text}"); + assert!(text.contains("\"public_key\":\"ECDSA (P-256)\""), "{text}"); + assert!(text.contains("\"fingerprint_sha256\":\""), "{text}"); + assert!(text.contains("\"extensions\":["), "{text}"); + + // The fingerprint must agree with botan when it is available. + if Command::new("botan").arg("version").output().is_ok() { + let botan = Command::new("botan") + .args(["cert_info", "--fingerprint"]) + .arg(&cert) + .output() + .unwrap(); + let botan_out = String::from_utf8_lossy(&botan.stdout); + let botan_fp = botan_out + .lines() + .find(|l| l.starts_with("Fingerprint:")) + .and_then(|l| l.split_once(": ")) + .map(|(_, fp)| fp.trim().to_lowercase()) + .expect("botan did not print a Fingerprint line"); + assert!(!botan_fp.is_empty()); + assert!( + text.contains(&botan_fp), + "fingerprint mismatch:\ncertkit json: {text}\nbotan: {botan_fp}" + ); + } +} + +#[test] +fn inspect_reads_der_from_stdin() { + let dir = tempdir().unwrap(); + let cert = dir.path().join("leaf.der"); + let key = dir.path().join("leaf.key"); + let status = certkit() + .args([ + "gen_self_signed", + "stdin.example.com", + "--format", + "der", + "--algorithm", + "Ed25519", + ]) + .arg("--key-out") + .arg(&key) + .arg("--out") + .arg(&cert) + .status() + .unwrap(); + assert!(status.success()); + let der = std::fs::read(&cert).unwrap(); + + let mut child = certkit() + .args(["cert_info", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(&der).unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("CN=stdin.example.com"), "{text}"); + assert!(text.contains("Ed25519"), "{text}"); +} diff --git a/certkit-cli/tests/keygen.rs b/certkit-cli/tests/keygen.rs new file mode 100644 index 0000000..cf58338 --- /dev/null +++ b/certkit-cli/tests/keygen.rs @@ -0,0 +1,56 @@ +//! Checks `keygen`'s Botan-style `--algorithm`/`--params` handling: valid +//! combinations succeed, and mismatched or unparseable `--params` are rejected +//! with a helpful message. + +pub mod common; + +use common::certkit; + +/// Runs `keygen `, asserts it failed, and returns its stderr. +fn keygen_fails(args: &[&str]) -> String { + let out = certkit().arg("keygen").args(args).output().unwrap(); + assert!( + !out.status.success(), + "expected `keygen {args:?}` to fail, but it succeeded" + ); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +/// Runs `keygen `, asserts success, and that a PEM key lands on stdout. +fn keygen_ok(args: &[&str]) { + let out = certkit().arg("keygen").args(args).output().unwrap(); + assert!( + out.status.success(), + "`keygen {args:?}` failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + out.stdout.starts_with(b"-----BEGIN"), + "expected a PEM private key on stdout for `keygen {args:?}`" + ); +} + +#[test] +fn rejects_unparseable_params() { + let err = keygen_fails(&["--algo", "ECDSA", "--params", "bogus"]); + assert!(err.contains("secp256r1"), "stderr: {err}"); +} + +#[test] +fn rejects_curve_for_rsa() { + let err = keygen_fails(&["--algo", "RSA", "--params", "secp256r1"]); + assert!(err.to_lowercase().contains("rsa"), "stderr: {err}"); +} + +#[test] +fn rejects_bits_for_ecdsa() { + let err = keygen_fails(&["--algo", "ECDSA", "--params", "2048"]); + assert!(err.to_lowercase().contains("ecdsa"), "stderr: {err}"); +} + +#[test] +fn accepts_valid_combinations() { + keygen_ok(&["--algo", "RSA", "--params", "2048"]); + keygen_ok(&["--algo", "ECDSA", "--params", "secp384r1"]); + keygen_ok(&["--algo", "Ed25519"]); +} diff --git a/src/cert/params.rs b/src/cert/params.rs index 4326909..4db5550 100644 --- a/src/cert/params.rs +++ b/src/cert/params.rs @@ -99,10 +99,8 @@ impl DistinguishedName { ]; for (oid, value) in optional_attrs { - if let Some(value) = value { - if !value.is_empty() { - rdns.push(dn_rdn(oid, value)?); - } + if let Some(value) = value.as_deref().filter(|v| !v.is_empty()) { + rdns.push(dn_rdn(oid, value)?); } }