From 6083e60aa925e6f46b9ba38368199e25ff70770d Mon Sep 17 00:00:00 2001 From: Nick Cardin Date: Fri, 19 Jun 2026 15:18:29 -0400 Subject: [PATCH 1/5] Add CLI --- .github/workflows/rust.yml | 4 +- Cargo.toml | 4 + README.md | 4 + certkit-cli/Cargo.toml | 32 +++++ certkit-cli/README.md | 69 +++++++++ certkit-cli/src/args.rs | 167 ++++++++++++++++++++++ certkit-cli/src/certs.rs | 77 +++++++++++ certkit-cli/src/cmd/inspect.rs | 49 +++++++ certkit-cli/src/cmd/issue.rs | 64 +++++++++ certkit-cli/src/cmd/keygen.rs | 36 +++++ certkit-cli/src/cmd/mod.rs | 10 ++ certkit-cli/src/cmd/self_signed.rs | 50 +++++++ certkit-cli/src/io.rs | 80 +++++++++++ certkit-cli/src/keys.rs | 59 ++++++++ certkit-cli/src/main.rs | 89 ++++++++++++ certkit-cli/src/report/extensions.rs | 131 ++++++++++++++++++ certkit-cli/src/report/fmt.rs | 111 +++++++++++++++ certkit-cli/src/report/mod.rs | 185 +++++++++++++++++++++++++ certkit-cli/tests/chain.rs | 104 ++++++++++++++ certkit-cli/tests/common.rs | 111 +++++++++++++++ certkit-cli/tests/inspect.rs | 200 +++++++++++++++++++++++++++ certkit-cli/tests/keygen.rs | 66 +++++++++ 22 files changed, 1700 insertions(+), 2 deletions(-) create mode 100644 certkit-cli/Cargo.toml create mode 100644 certkit-cli/README.md create mode 100644 certkit-cli/src/args.rs create mode 100644 certkit-cli/src/certs.rs create mode 100644 certkit-cli/src/cmd/inspect.rs create mode 100644 certkit-cli/src/cmd/issue.rs create mode 100644 certkit-cli/src/cmd/keygen.rs create mode 100644 certkit-cli/src/cmd/mod.rs create mode 100644 certkit-cli/src/cmd/self_signed.rs create mode 100644 certkit-cli/src/io.rs create mode 100644 certkit-cli/src/keys.rs create mode 100644 certkit-cli/src/main.rs create mode 100644 certkit-cli/src/report/extensions.rs create mode 100644 certkit-cli/src/report/fmt.rs create mode 100644 certkit-cli/src/report/mod.rs create mode 100644 certkit-cli/tests/chain.rs create mode 100644 certkit-cli/tests/common.rs create mode 100644 certkit-cli/tests/inspect.rs create mode 100644 certkit-cli/tests/keygen.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 5e406e9..28813a2 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: ["master"] + branches: master pull_request: - branches: ["master"] + branches: master env: CARGO_TERM_COLOR: always diff --git a/Cargo.toml b/Cargo.toml index a2730da..0ea5610 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ +[workspace] +members = ["certkit-cli"] +resolver = "2" + [package] name = "certkit" version = "0.2.0" 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..0d1b95e --- /dev/null +++ b/certkit-cli/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "certkit-cli" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +license = "MIT OR Apache-2.0" +description = "Command-line interface for certkit: generate keys and X.509 certificates." +repository = "https://github.com/nacardin/certkit.git" +homepage = "https://github.com/nacardin/certkit" +readme = "README.md" +keywords = ["x509", "certificate", "cli", "pki", "tls"] +categories = ["cryptography", "command-line-utilities"] +authors = ["Nick Cardin "] + +[[bin]] +name = "certkit" +path = "src/main.rs" + +[dependencies] +certkit = { path = "..", version = "0.2.0" } +clap = { version = "4", features = ["derive"] } +const-oid = { version = "0.9", features = ["db"] } +der = "0.7" +env_logger = "0.11" +log = "0.4" +rsa = "0.9" +sha2 = "0.10" +time = "0.3" +x509-cert = "0.2.5" + +[dev-dependencies] +tempfile = "3" diff --git a/certkit-cli/README.md b/certkit-cli/README.md new file mode 100644 index 0000000..e9d41ab --- /dev/null +++ b/certkit-cli/README.md @@ -0,0 +1,69 @@ +# 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. + +## Algorithms + +`--algorithm` (alias `--algo`, short `-a`) accepts `RSA`, `ECDSA`, and +`Ed25519` (case-insensitive). The key shape is set with `--params`, following +Botan: + +- `--algo RSA --params 3072` — RSA key size in bits (default 2048). +- `--algo ECDSA --params secp256r1` — curve `secp256r1`, `secp384r1`, or + `secp521r1` (default `secp256r1`). +- `--algo Ed25519` — no parameters. diff --git a/certkit-cli/src/args.rs b/certkit-cli/src/args.rs new file mode 100644 index 0000000..299d1d8 --- /dev/null +++ b/certkit-cli/src/args.rs @@ -0,0 +1,167 @@ +//! Shared command-line argument types. +//! +//! These are the clap derive pieces reused across subcommands: the value enums +//! describing algorithms and output formats, and the `Args` groups +//! ([`DnArgs`], [`KeySourceArgs`], [`CertOptArgs`]) flattened into the +//! certificate subcommands. Each subcommand's own options struct lives with its +//! handler under [`cmd`](crate::cmd). Fields are `pub` so those handlers +//! and their helpers can read them. + +use std::path::PathBuf; + +use clap::{Args, ValueEnum}; + +use certkit::cert::extensions::ExtendedKeyUsageOption; + +/// Key algorithm, named as Botan's `keygen --algo` expects. +/// +/// The key shape (RSA size, ECDSA curve) is selected with `--params`, also +/// following Botan: `--params 2048` for RSA, `--params secp256r1` for ECDSA. +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum Algorithm { + #[value(name = "RSA")] + Rsa, + #[value(name = "ECDSA")] + Ecdsa, + #[value(name = "Ed25519")] + Ed25519, +} + +/// Key parameters chosen with `--params`, disambiguated by value: a bare number +/// is an RSA key size, anything else is an ECDSA curve name (as in Botan). +#[derive(Copy, Clone, Debug)] +pub enum KeyParams { + /// RSA modulus size in bits. + Bits(u32), + /// ECDSA curve. + Curve(Curve), +} + +/// A NIST/SECG curve certkit can generate. +#[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)) + } +} + +/// Output encoding for certificates. +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +pub enum CertFormat { + Pem, + Der, +} + +/// Extended Key Usage purposes that can be requested for a certificate. +#[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, + } + } +} + +/// Subject distinguished name fields, shared by the certificate subcommands. +#[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, +} + +/// How to obtain the subject key pair, shared by the certificate subcommands. +#[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 for a freshly generated key: RSA size in bits (default + /// 2048) or ECDSA curve (secp256r1, secp384r1, secp521r1; 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, +} + +/// Extension, validity, and output options, shared by the certificate subcommands. +#[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/certs.rs b/certkit-cli/src/certs.rs new file mode 100644 index 0000000..7785cfd --- /dev/null +++ b/certkit-cli/src/certs.rs @@ -0,0 +1,77 @@ +use std::fs; +use std::path::Path; + +use der::Encode; +use der::asn1::Ia5String; +use x509_cert::ext::pkix; +use x509_cert::ext::pkix::name::GeneralName; + +use certkit::cert::Certificate; +use certkit::cert::extensions::{ExtendedKeyUsageOption, SubjectAltName, ToAndFromX509Extension}; +use certkit::cert::params::{CertificateParams, DistinguishedName, ExtensionParam}; +use certkit::key::{KeyPair, PublicKey}; + +use crate::Result; +use crate::args::{CertOptArgs, DnArgs}; + +/// Builds the certification request info from the DN, key, and options. +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()) +} + +/// Builds a Subject Alternative Name extension from DNS and email entries. +/// +/// certkit's own `SubjectAltName` only models DNS names, so the extension is +/// assembled directly from `x509_cert` general names to also carry rfc822 +/// (email) entries, then wrapped as a raw `ExtensionParam`. +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(|_| format!("invalid DNS name: {name}"))?; + names.push(GeneralName::DnsName(ia5)); + } + for addr in email { + let ia5 = Ia5String::try_from(addr.clone()) + .map_err(|_| format!("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()?, + })) +} + +/// Loads a CA certificate from a PEM or DER file (auto-detected). +pub fn load_ca_cert(path: &Path) -> Result { + Ok(Certificate::from_bytes(&fs::read(path)?)?) +} diff --git a/certkit-cli/src/cmd/inspect.rs b/certkit-cli/src/cmd/inspect.rs new file mode 100644 index 0000000..6d8d48a --- /dev/null +++ b/certkit-cli/src/cmd/inspect.rs @@ -0,0 +1,49 @@ +//! `cert_info` — parse a certificate and print its fields. + +use std::io::Write; +use std::path::PathBuf; + +use clap::Args; + +use certkit::cert::Certificate; + +use crate::Result; +use crate::io::read_cert_input; +use crate::report::CertReport; + +#[derive(Args)] +pub struct InspectOpt { + /// Certificate to read (PEM or DER, auto-detected). Omit or `-` for stdin. + pub input: Option, + /// 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<()> { + let source = self + .input + .as_deref() + .filter(|p| p.as_os_str() != "-") + .map_or_else(|| "stdin".to_string(), |p| p.display().to_string()); + log::debug!("inspecting certificate from {source}"); + + 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..9aa9d79 --- /dev/null +++ b/certkit-cli/src/cmd/issue.rs @@ -0,0 +1,64 @@ +//! `issue` — issue a certificate signed by an existing CA. + +use std::fs; +use std::path::PathBuf; + +use clap::Args; + +use certkit::cert::CertificateWithPrivateKey; +use certkit::cert::params::Validity; +use certkit::issuer::Issuer; +use certkit::key::KeyPair; + +use crate::Result; +use crate::args::{CertOptArgs, DnArgs, KeySourceArgs}; +use crate::certs::{cert_info, load_ca_cert}; +use crate::io::{emit, guard_stdout_clash}; +use crate::keys::key_pair; + +#[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..5eaa47a --- /dev/null +++ b/certkit-cli/src/cmd/keygen.rs @@ -0,0 +1,36 @@ +//! `keygen` — generate a private key and write it as PKCS#8 PEM. + +use std::path::PathBuf; + +use clap::Args; + +use crate::Result; +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 size in bits (default 2048) or ECDSA curve + /// (secp256r1, secp384r1, secp521r1; 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..f0269fe --- /dev/null +++ b/certkit-cli/src/cmd/mod.rs @@ -0,0 +1,10 @@ +//! Subcommand implementations. +//! +//! Each submodule defines one subcommand's options struct (`#[derive(Args)]`) +//! and an `execute(&self) -> Result<()>` method holding that command's logic. +//! [`crate::Command`] wraps these options structs and dispatches to `execute`. + +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..8a8dc9d --- /dev/null +++ b/certkit-cli/src/cmd/self_signed.rs @@ -0,0 +1,50 @@ +//! `gen_self_signed` — create a self-signed certificate (optionally a CA). + +use clap::Args; +use time::{Duration, OffsetDateTime}; + +use certkit::cert::Certificate; + +use crate::Result; +use crate::args::{CertOptArgs, DnArgs, KeySourceArgs}; +use crate::certs::cert_info; +use crate::io::{emit, guard_stdout_clash}; +use crate::keys::key_pair; + +#[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..ece2b55 --- /dev/null +++ b/certkit-cli/src/io.rs @@ -0,0 +1,80 @@ +//! Reading input and writing certificates and keys. +//! +//! Output goes to the path given on the command line, or to stdout when none is +//! given; informational "wrote ..." messages go to stderr so stdout stays clean +//! for piping. [`guard_stdout_clash`] refuses the one combination that would +//! corrupt stdout: a binary DER certificate and a PEM key both written there. + +use std::fs; +use std::io::{Read, Write}; +use std::path::PathBuf; + +use certkit::cert::Certificate; +use certkit::key::KeyPair; + +use crate::Result; +use crate::args::{CertFormat, CertOptArgs}; + +/// Reads certificate bytes from a file, or from stdin when the path is absent or `-`. +pub fn read_cert_input(path: &Option) -> Result> { + match path.as_deref().filter(|p| p.as_os_str() != "-") { + Some(path) => Ok(fs::read(path)?), + None => { + let mut buf = Vec::new(); + std::io::stdin().lock().read_to_end(&mut buf)?; + Ok(buf) + } + } +} + +/// Writes the certificate and, when one was generated, the private key. +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(()) +} + +/// Refuses to interleave a binary DER certificate and a PEM key on stdout. +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 { + return Err("refusing to write a binary DER certificate and a PEM private key both to stdout; pass --out and/or --key-out".into()); + } + Ok(()) +} + +/// Writes bytes to a file when a path is given, otherwise to stdout. +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..450f49f --- /dev/null +++ b/certkit-cli/src/keys.rs @@ -0,0 +1,59 @@ +//! Obtaining the subject key pair from the command line. +//! +//! [`generate`] turns an [`Algorithm`] plus `--params` into a fresh [`KeyPair`], +//! and [`key_pair`] either loads an existing key (`--key`) or generates one. + +use std::fs; + +use certkit::key::KeyPair; + +use crate::Result; +use crate::args::{Algorithm, Curve, KeyParams, KeySourceArgs}; + +/// Generates a key pair for the requested algorithm and `--params`. +/// +/// `--params` is parsed (and curve names normalized) when the arguments are +/// read, so here it only remains to reject a parameter that doesn't match the +/// chosen algorithm — e.g. a curve for RSA, or a key size for ECDSA. +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(_)) => { + return Err("RSA takes a key size, not a curve; e.g. --params 2048".into()); + } + None => 2048, + }; + Ok(KeyPair::generate_rsa(bits)?) + } + Algorithm::Ecdsa => { + let curve = match params { + Some(KeyParams::Curve(curve)) => curve, + Some(KeyParams::Bits(_)) => { + return Err( + "ECDSA takes a curve, not a key size; e.g. --params secp256r1".into(), + ); + } + 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()), + } +} + +/// Loads the key from `--key`, or generates one. Returns `(key, generated)`. +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..90f2e29 --- /dev/null +++ b/certkit-cli/src/main.rs @@ -0,0 +1,89 @@ +//! Command-line interface for [`certkit`]. +//! +//! Subcommand names follow Botan's CLI: +//! - `keygen` — generate a private key and emit it as PKCS#8 PEM. +//! - `gen_self_signed` — create a self-signed certificate (optionally a CA). +//! - `issue` — issue a certificate signed by an existing CA certificate/key. +//! - `cert_info` — parse a certificate and print its fields. +//! +//! Certificate data is written to `--out` (or stdout); a freshly generated +//! private key is written to `--key-out` (or stdout). Informational messages go +//! to stderr so stdout stays clean for piping. +//! +//! The crate is organized into: +//! - [`cmd`] — one module per subcommand, each an options struct with an +//! `execute` method. +//! - [`args`] — argument types (value enums and `Args` groups) shared between +//! subcommands. +//! - [`keys`] — generating or loading the subject key pair. +//! - [`certs`] — assembling and parsing certificates. +//! - [`io`] — reading input and writing certificates/keys to files or stdout. +//! - [`report`] — decoding a parsed certificate and rendering it as text or JSON. + +mod args; +mod certs; +mod cmd; +mod io; +mod keys; +mod report; + +use std::process; + +use clap::{Parser, Subcommand}; + +use cmd::inspect::InspectOpt; +use cmd::issue::IssueOpt; +use cmd::keygen::KeygenOpt; +use cmd::self_signed::SelfSignedOpt; + +/// Error type shared across the CLI; any error is boxed and printed by `main`. +pub type Result = std::result::Result>; + +#[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() { + // Logs go to stderr (keeping stdout clean for piping). The default level is `info`. + 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/report/extensions.rs b/certkit-cli/src/report/extensions.rs new file mode 100644 index 0000000..7ab0840 --- /dev/null +++ b/certkit-cli/src/report/extensions.rs @@ -0,0 +1,131 @@ +//! Decoding X.509 extensions into renderable summaries. +//! +//! [`describe_extension`] dispatches on the extension OID to one of the +//! per-extension summarizers below, each of which DER-decodes the value and +//! produces a short human-readable string (falling back to +//! [`UNDECODABLE`](super::fmt) when decoding fails). + +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}; + +/// Decodes a single extension into a renderable summary. +/// +/// Each arm matches the extension's OID against the [`AssociatedOid::OID`] of +/// the very type used to decode it, so the OID and its decoder can never drift +/// apart. `ObjectIdentifier` isn't usable in `match` patterns, hence the +/// `if`/`else if` 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(), + } +} + +/// Renders a `GeneralName` for the SAN summary. +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..0a664e4 --- /dev/null +++ b/certkit-cli/src/report/fmt.rs @@ -0,0 +1,111 @@ +//! Low-level formatting primitives shared by the report renderers. +//! +//! These turn raw bytes, OIDs, and strings into the human- and JSON-friendly +//! fragments that [`super`] and [`super::extensions`] assemble into output. + +use const_oid::ObjectIdentifier; +use const_oid::db::{rfc5280, rfc5912, rfc8410}; + +/// Placeholder shown when an extension's value cannot be DER-decoded. +pub(super) const UNDECODABLE: &str = "(undecodable)"; + +/// Lowercase hex with colon separators, e.g. `9f:86:d0`. +pub(super) fn hex_colons(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") +} + +/// Formats raw IP-address octets: dotted-quad for v4, colon-hex for v6. +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), + } +} + +/// Maps an OID to a friendly name. +/// +/// Known Extended Key Usage purposes and signature algorithms get a curated +/// short name; the OID identity comes from const-oid's named constants rather +/// than dotted-string literals. For anything else, we fall back to const-oid's +/// name database (e.g. `id-ecPublicKey`), and finally to the 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() +} + +/// Joins parts with commas, or reports `(none)` when empty. +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(", ") + } +} + +/// Encodes a string as a JSON string literal (quotes + minimal escaping). +pub(super) fn json_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} diff --git a/certkit-cli/src/report/mod.rs b/certkit-cli/src/report/mod.rs new file mode 100644 index 0000000..a036cd1 --- /dev/null +++ b/certkit-cli/src/report/mod.rs @@ -0,0 +1,185 @@ +//! Rendering a parsed certificate for the `cert_info` subcommand. +//! +//! [`CertReport`] is a flat, render-ready view of the fields `cert_info` +//! reports. [`CertReport::from_cert`] decodes an [`X509Certificate`] into it +//! (delegating extension decoding to [`extensions`] and byte/OID formatting to +//! [`fmt`]), and [`CertReport::to_text`]/[`CertReport::to_json`] render it. + +mod extensions; +mod fmt; + +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; +use x509_cert::Certificate as X509Certificate; + +use certkit::cert::Certificate; +use certkit::key::PublicKey; + +use crate::Result; +use extensions::describe_extension; +use fmt::{describe_oid, hex_colons, json_string}; + +/// A decoded extension, ready to render in either output format. +struct ExtReport { + oid: String, + name: &'static str, + critical: bool, + summary: String, +} + +/// The fields of a certificate that `cert_info` reports. +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 fingerprint = match &self.fingerprint_sha256 { + Some(fp) => json_string(fp), + None => "null".to_string(), + }; + let extensions: Vec = self + .extensions + .iter() + .map(|ext| { + format!( + "{{\"oid\":{},\"name\":{},\"critical\":{},\"summary\":{}}}", + json_string(&ext.oid), + json_string(ext.name), + ext.critical, + json_string(&ext.summary), + ) + }) + .collect(); + + format!( + concat!( + "{{\"subject\":{},\"issuer\":{},\"serial\":{},", + "\"not_before\":{},\"not_after\":{},\"expired\":{},", + "\"not_yet_valid\":{},\"days_remaining\":{},\"public_key\":{},", + "\"signature_algorithm\":{},\"fingerprint_sha256\":{},", + "\"extensions\":[{}]}}\n" + ), + json_string(&self.subject), + json_string(&self.issuer), + json_string(&self.serial), + json_string(&self.not_before), + json_string(&self.not_after), + self.expired, + self.not_yet_valid, + self.days_remaining, + json_string(&self.public_key), + json_string(&self.signature_algorithm), + fingerprint, + extensions.join(","), + ) + } +} + +/// Names the subject's public key algorithm (and size, for RSA). +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..e2af702 --- /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. + +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..0c5dcec --- /dev/null +++ b/certkit-cli/tests/inspect.rs @@ -0,0 +1,200 @@ +//! 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. + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +use tempfile::tempdir; + +/// A `Command` for the `certkit` binary under test. +/// +/// Defaults the binary's logging to `error` for quiet output, while still +/// honoring an explicit `RUST_LOG` so `RUST_LOG=debug cargo test` surfaces logs. +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 +} + +/// 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..2ea0a3f --- /dev/null +++ b/certkit-cli/tests/keygen.rs @@ -0,0 +1,66 @@ +//! Checks `keygen`'s Botan-style `--algorithm`/`--params` handling: valid +//! combinations succeed, and mismatched or unparseable `--params` are rejected +//! with a helpful message. + +use std::process::Command; + +/// A `Command` for the `certkit` binary under test. +/// +/// Defaults the binary's logging to `error` for quiet output, while still +/// honoring an explicit `RUST_LOG` so `RUST_LOG=debug cargo test` surfaces logs. +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 +} + +/// 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"]); +} From 1d8efeac794201923371b50417495cd64fd31662 Mon Sep 17 00:00:00 2001 From: Nick Cardin Date: Fri, 19 Jun 2026 21:13:14 -0400 Subject: [PATCH 2/5] cleanup --- .github/workflows/rust.yml | 4 +- Cargo.toml | 47 ++++++++++++++------ certkit-cli/Cargo.toml | 32 ++++++++------ certkit-cli/README.md | 11 ----- certkit-cli/src/args.rs | 30 ++----------- certkit-cli/src/cmd/inspect.rs | 4 +- certkit-cli/src/cmd/issue.rs | 8 ++-- certkit-cli/src/cmd/keygen.rs | 7 +-- certkit-cli/src/cmd/mod.rs | 6 --- certkit-cli/src/cmd/self_signed.rs | 6 +-- certkit-cli/src/io.rs | 22 ++++----- certkit-cli/src/keys.rs | 20 ++------- certkit-cli/src/main.rs | 29 +----------- certkit-cli/src/{certs.rs => params.rs} | 23 +++------- certkit-cli/src/report/extensions.rs | 16 +------ certkit-cli/src/report/fmt.rs | 36 +-------------- certkit-cli/src/report/mod.rs | 59 ++++--------------------- certkit-cli/tests/chain.rs | 2 +- certkit-cli/tests/inspect.rs | 15 ++----- certkit-cli/tests/keygen.rs | 14 +----- 20 files changed, 103 insertions(+), 288 deletions(-) rename certkit-cli/src/{certs.rs => params.rs} (71%) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 28813a2..5e406e9 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: master + branches: ["master"] pull_request: - branches: master + branches: ["master"] env: CARGO_TERM_COLOR: always diff --git a/Cargo.toml b/Cargo.toml index 0ea5610..54292ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,45 +2,64 @@ members = ["certkit-cli"] resolver = "2" -[package] -name = "certkit" -version = "0.2.0" +[workspace.package] edition = "2024" rust-version = "1.85" 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/certkit-cli/Cargo.toml b/certkit-cli/Cargo.toml index 0d1b95e..f4abd58 100644 --- a/certkit-cli/Cargo.toml +++ b/certkit-cli/Cargo.toml @@ -1,16 +1,16 @@ [package] name = "certkit-cli" version = "0.1.0" -edition = "2024" -rust-version = "1.85" -license = "MIT OR Apache-2.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true description = "Command-line interface for certkit: generate keys and X.509 certificates." -repository = "https://github.com/nacardin/certkit.git" -homepage = "https://github.com/nacardin/certkit" +repository.workspace = true +homepage.workspace = true readme = "README.md" keywords = ["x509", "certificate", "cli", "pki", "tls"] categories = ["cryptography", "command-line-utilities"] -authors = ["Nick Cardin "] +authors.workspace = true [[bin]] name = "certkit" @@ -18,15 +18,19 @@ path = "src/main.rs" [dependencies] certkit = { path = "..", version = "0.2.0" } +anyhow = "1" clap = { version = "4", features = ["derive"] } -const-oid = { version = "0.9", features = ["db"] } -der = "0.7" -env_logger = "0.11" -log = "0.4" -rsa = "0.9" -sha2 = "0.10" -time = "0.3" -x509-cert = "0.2.5" +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 index e9d41ab..cdffa1a 100644 --- a/certkit-cli/README.md +++ b/certkit-cli/README.md @@ -56,14 +56,3 @@ cat cert.pem | certkit cert_info - ``` Run `certkit --help` for the full set of options. - -## Algorithms - -`--algorithm` (alias `--algo`, short `-a`) accepts `RSA`, `ECDSA`, and -`Ed25519` (case-insensitive). The key shape is set with `--params`, following -Botan: - -- `--algo RSA --params 3072` — RSA key size in bits (default 2048). -- `--algo ECDSA --params secp256r1` — curve `secp256r1`, `secp384r1`, or - `secp521r1` (default `secp256r1`). -- `--algo Ed25519` — no parameters. diff --git a/certkit-cli/src/args.rs b/certkit-cli/src/args.rs index 299d1d8..bb2483d 100644 --- a/certkit-cli/src/args.rs +++ b/certkit-cli/src/args.rs @@ -1,22 +1,11 @@ -//! Shared command-line argument types. -//! -//! These are the clap derive pieces reused across subcommands: the value enums -//! describing algorithms and output formats, and the `Args` groups -//! ([`DnArgs`], [`KeySourceArgs`], [`CertOptArgs`]) flattened into the -//! certificate subcommands. Each subcommand's own options struct lives with its -//! handler under [`cmd`](crate::cmd). Fields are `pub` so those handlers -//! and their helpers can read them. - use std::path::PathBuf; use clap::{Args, ValueEnum}; use certkit::cert::extensions::ExtendedKeyUsageOption; -/// Key algorithm, named as Botan's `keygen --algo` expects. -/// -/// The key shape (RSA size, ECDSA curve) is selected with `--params`, also -/// following Botan: `--params 2048` for RSA, `--params secp256r1` for ECDSA. +/// 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")] @@ -27,17 +16,13 @@ pub enum Algorithm { Ed25519, } -/// Key parameters chosen with `--params`, disambiguated by value: a bare number -/// is an RSA key size, anything else is an ECDSA curve name (as in Botan). +/// A bare number is RSA bits, anything else is an ECDSA curve name. #[derive(Copy, Clone, Debug)] pub enum KeyParams { - /// RSA modulus size in bits. Bits(u32), - /// ECDSA curve. Curve(Curve), } -/// A NIST/SECG curve certkit can generate. #[derive(Copy, Clone, Debug)] pub enum Curve { P256, @@ -67,14 +52,12 @@ impl std::str::FromStr for KeyParams { } } -/// Output encoding for certificates. #[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] pub enum CertFormat { Pem, Der, } -/// Extended Key Usage purposes that can be requested for a certificate. #[derive(Copy, Clone, Debug, ValueEnum)] pub enum EkuOpt { ServerAuth, @@ -98,7 +81,6 @@ impl From for ExtendedKeyUsageOption { } } -/// Subject distinguished name fields, shared by the certificate subcommands. #[derive(Args)] pub struct DnArgs { /// Subject common name (CN), given positionally (as in Botan). @@ -121,15 +103,12 @@ pub struct DnArgs { pub organization_unit: Option, } -/// How to obtain the subject key pair, shared by the certificate subcommands. #[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 for a freshly generated key: RSA size in bits (default - /// 2048) or ECDSA curve (secp256r1, secp384r1, secp521r1; default - /// secp256r1). Ignored for Ed25519 and when `--key` is given. + /// 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. @@ -140,7 +119,6 @@ pub struct KeySourceArgs { pub key_out: Option, } -/// Extension, validity, and output options, shared by the certificate subcommands. #[derive(Args)] pub struct CertOptArgs { /// DNS Subject Alternative Name (repeatable). diff --git a/certkit-cli/src/cmd/inspect.rs b/certkit-cli/src/cmd/inspect.rs index 6d8d48a..bb2cbd7 100644 --- a/certkit-cli/src/cmd/inspect.rs +++ b/certkit-cli/src/cmd/inspect.rs @@ -1,13 +1,11 @@ -//! `cert_info` — parse a certificate and print its fields. - use std::io::Write; use std::path::PathBuf; +use anyhow::Result; use clap::Args; use certkit::cert::Certificate; -use crate::Result; use crate::io::read_cert_input; use crate::report::CertReport; diff --git a/certkit-cli/src/cmd/issue.rs b/certkit-cli/src/cmd/issue.rs index 9aa9d79..2ff7047 100644 --- a/certkit-cli/src/cmd/issue.rs +++ b/certkit-cli/src/cmd/issue.rs @@ -1,8 +1,7 @@ -//! `issue` — issue a certificate signed by an existing CA. - use std::fs; use std::path::PathBuf; +use anyhow::Result; use clap::Args; use certkit::cert::CertificateWithPrivateKey; @@ -10,11 +9,10 @@ use certkit::cert::params::Validity; use certkit::issuer::Issuer; use certkit::key::KeyPair; -use crate::Result; use crate::args::{CertOptArgs, DnArgs, KeySourceArgs}; -use crate::certs::{cert_info, load_ca_cert}; -use crate::io::{emit, guard_stdout_clash}; +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 { diff --git a/certkit-cli/src/cmd/keygen.rs b/certkit-cli/src/cmd/keygen.rs index 5eaa47a..58cc7ee 100644 --- a/certkit-cli/src/cmd/keygen.rs +++ b/certkit-cli/src/cmd/keygen.rs @@ -1,10 +1,8 @@ -//! `keygen` — generate a private key and write it as PKCS#8 PEM. - use std::path::PathBuf; +use anyhow::Result; use clap::Args; -use crate::Result; use crate::args::{Algorithm, KeyParams}; use crate::io::write_bytes; use crate::keys::generate; @@ -14,8 +12,7 @@ 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 size in bits (default 2048) or ECDSA curve - /// (secp256r1, secp384r1, secp521r1; default secp256r1). Ignored for Ed25519. + /// 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. diff --git a/certkit-cli/src/cmd/mod.rs b/certkit-cli/src/cmd/mod.rs index f0269fe..45c0a98 100644 --- a/certkit-cli/src/cmd/mod.rs +++ b/certkit-cli/src/cmd/mod.rs @@ -1,9 +1,3 @@ -//! Subcommand implementations. -//! -//! Each submodule defines one subcommand's options struct (`#[derive(Args)]`) -//! and an `execute(&self) -> Result<()>` method holding that command's logic. -//! [`crate::Command`] wraps these options structs and dispatches to `execute`. - pub mod inspect; pub mod issue; pub mod keygen; diff --git a/certkit-cli/src/cmd/self_signed.rs b/certkit-cli/src/cmd/self_signed.rs index 8a8dc9d..e3f2d24 100644 --- a/certkit-cli/src/cmd/self_signed.rs +++ b/certkit-cli/src/cmd/self_signed.rs @@ -1,15 +1,13 @@ -//! `gen_self_signed` — create a self-signed certificate (optionally a CA). - +use anyhow::Result; use clap::Args; use time::{Duration, OffsetDateTime}; use certkit::cert::Certificate; -use crate::Result; use crate::args::{CertOptArgs, DnArgs, KeySourceArgs}; -use crate::certs::cert_info; use crate::io::{emit, guard_stdout_clash}; use crate::keys::key_pair; +use crate::params::cert_info; #[derive(Args)] pub struct SelfSignedOpt { diff --git a/certkit-cli/src/io.rs b/certkit-cli/src/io.rs index ece2b55..18f84cb 100644 --- a/certkit-cli/src/io.rs +++ b/certkit-cli/src/io.rs @@ -1,21 +1,13 @@ -//! Reading input and writing certificates and keys. -//! -//! Output goes to the path given on the command line, or to stdout when none is -//! given; informational "wrote ..." messages go to stderr so stdout stays clean -//! for piping. [`guard_stdout_clash`] refuses the one combination that would -//! corrupt stdout: a binary DER certificate and a PEM key both written there. - 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::Result; use crate::args::{CertFormat, CertOptArgs}; -/// Reads certificate bytes from a file, or from stdin when the path is absent or `-`. pub fn read_cert_input(path: &Option) -> Result> { match path.as_deref().filter(|p| p.as_os_str() != "-") { Some(path) => Ok(fs::read(path)?), @@ -27,7 +19,6 @@ pub fn read_cert_input(path: &Option) -> Result> { } } -/// Writes the certificate and, when one was generated, the private key. pub fn emit( cert: &Certificate, key: Option<&KeyPair>, @@ -53,7 +44,7 @@ pub fn emit( Ok(()) } -/// Refuses to interleave a binary DER certificate and a PEM key on stdout. +/// Rejects binary DER cert + PEM key both going to stdout (would corrupt output). pub fn guard_stdout_clash( generated: bool, key_out: &Option, @@ -61,12 +52,17 @@ pub fn guard_stdout_clash( format: CertFormat, ) -> Result<()> { if generated && key_out.is_none() && cert_out.is_none() && format == CertFormat::Der { - return Err("refusing to write a binary DER certificate and a PEM private key both to stdout; pass --out and/or --key-out".into()); + bail!( + "refusing to write a binary DER certificate and a PEM private key both to stdout; pass --out and/or --key-out" + ); } Ok(()) } -/// Writes bytes to a file when a path is given, otherwise to stdout. +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)?, diff --git a/certkit-cli/src/keys.rs b/certkit-cli/src/keys.rs index 450f49f..d111339 100644 --- a/certkit-cli/src/keys.rs +++ b/certkit-cli/src/keys.rs @@ -1,27 +1,17 @@ -//! Obtaining the subject key pair from the command line. -//! -//! [`generate`] turns an [`Algorithm`] plus `--params` into a fresh [`KeyPair`], -//! and [`key_pair`] either loads an existing key (`--key`) or generates one. - use std::fs; +use anyhow::{Result, bail}; use certkit::key::KeyPair; -use crate::Result; use crate::args::{Algorithm, Curve, KeyParams, KeySourceArgs}; -/// Generates a key pair for the requested algorithm and `--params`. -/// -/// `--params` is parsed (and curve names normalized) when the arguments are -/// read, so here it only remains to reject a parameter that doesn't match the -/// chosen algorithm — e.g. a curve for RSA, or a key size for ECDSA. 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(_)) => { - return Err("RSA takes a key size, not a curve; e.g. --params 2048".into()); + bail!("RSA takes a key size, not a curve; e.g. --params 2048"); } None => 2048, }; @@ -31,9 +21,7 @@ pub fn generate(algorithm: Algorithm, params: Option) -> Result curve, Some(KeyParams::Bits(_)) => { - return Err( - "ECDSA takes a curve, not a key size; e.g. --params secp256r1".into(), - ); + bail!("ECDSA takes a curve, not a key size; e.g. --params secp256r1"); } None => Curve::P256, }; @@ -47,7 +35,7 @@ pub fn generate(algorithm: Algorithm, params: Option) -> Result Result<(KeyPair, bool)> { match &args.key { Some(path) => Ok(( diff --git a/certkit-cli/src/main.rs b/certkit-cli/src/main.rs index 90f2e29..4ecae90 100644 --- a/certkit-cli/src/main.rs +++ b/certkit-cli/src/main.rs @@ -1,34 +1,13 @@ -//! Command-line interface for [`certkit`]. -//! -//! Subcommand names follow Botan's CLI: -//! - `keygen` — generate a private key and emit it as PKCS#8 PEM. -//! - `gen_self_signed` — create a self-signed certificate (optionally a CA). -//! - `issue` — issue a certificate signed by an existing CA certificate/key. -//! - `cert_info` — parse a certificate and print its fields. -//! -//! Certificate data is written to `--out` (or stdout); a freshly generated -//! private key is written to `--key-out` (or stdout). Informational messages go -//! to stderr so stdout stays clean for piping. -//! -//! The crate is organized into: -//! - [`cmd`] — one module per subcommand, each an options struct with an -//! `execute` method. -//! - [`args`] — argument types (value enums and `Args` groups) shared between -//! subcommands. -//! - [`keys`] — generating or loading the subject key pair. -//! - [`certs`] — assembling and parsing certificates. -//! - [`io`] — reading input and writing certificates/keys to files or stdout. -//! - [`report`] — decoding a parsed certificate and rendering it as text or JSON. - mod args; -mod certs; mod cmd; mod io; mod keys; +mod params; mod report; use std::process; +use anyhow::Result; use clap::{Parser, Subcommand}; use cmd::inspect::InspectOpt; @@ -36,9 +15,6 @@ use cmd::issue::IssueOpt; use cmd::keygen::KeygenOpt; use cmd::self_signed::SelfSignedOpt; -/// Error type shared across the CLI; any error is boxed and printed by `main`. -pub type Result = std::result::Result>; - #[derive(Parser)] #[command( name = "certkit", @@ -67,7 +43,6 @@ enum Command { } fn main() { - // Logs go to stderr (keeping stdout clean for piping). The default level is `info`. env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .format_timestamp(None) .init(); diff --git a/certkit-cli/src/certs.rs b/certkit-cli/src/params.rs similarity index 71% rename from certkit-cli/src/certs.rs rename to certkit-cli/src/params.rs index 7785cfd..ab8eacb 100644 --- a/certkit-cli/src/certs.rs +++ b/certkit-cli/src/params.rs @@ -1,20 +1,15 @@ -use std::fs; -use std::path::Path; - +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::Certificate; use certkit::cert::extensions::{ExtendedKeyUsageOption, SubjectAltName, ToAndFromX509Extension}; use certkit::cert::params::{CertificateParams, DistinguishedName, ExtensionParam}; use certkit::key::{KeyPair, PublicKey}; -use crate::Result; use crate::args::{CertOptArgs, DnArgs}; -/// Builds the certification request info from the DN, key, and options. pub fn cert_info(dn: &DnArgs, key: &KeyPair, opts: &CertOptArgs) -> Result { let subject = DistinguishedName::builder() .common_name(dn.common_name.clone()) @@ -41,11 +36,8 @@ pub fn cert_info(dn: &DnArgs, key: &KeyPair, opts: &CertOptArgs) -> Result Result> { if dns.is_empty() && email.is_empty() { return Ok(None); @@ -54,12 +46,12 @@ fn build_san(dns: &[String], email: &[String]) -> Result> let mut names = Vec::new(); for name in dns { let ia5 = - Ia5String::try_from(name.clone()).map_err(|_| format!("invalid DNS name: {name}"))?; + 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(|_| format!("invalid email address: {addr}"))?; + .map_err(|_| anyhow!("invalid email address: {addr}"))?; names.push(GeneralName::Rfc822Name(ia5)); } @@ -70,8 +62,3 @@ fn build_san(dns: &[String], email: &[String]) -> Result> value: san.to_der()?, })) } - -/// Loads a CA certificate from a PEM or DER file (auto-detected). -pub fn load_ca_cert(path: &Path) -> Result { - Ok(Certificate::from_bytes(&fs::read(path)?)?) -} diff --git a/certkit-cli/src/report/extensions.rs b/certkit-cli/src/report/extensions.rs index 7ab0840..f064b7b 100644 --- a/certkit-cli/src/report/extensions.rs +++ b/certkit-cli/src/report/extensions.rs @@ -1,10 +1,3 @@ -//! Decoding X.509 extensions into renderable summaries. -//! -//! [`describe_extension`] dispatches on the extension OID to one of the -//! per-extension summarizers below, each of which DER-decodes the value and -//! produces a short human-readable string (falling back to -//! [`UNDECODABLE`](super::fmt) when decoding fails). - use der::Decode; use der::oid::AssociatedOid; use x509_cert::ext::pkix; @@ -13,12 +6,8 @@ use x509_cert::ext::pkix::name::GeneralName; use super::ExtReport; use super::fmt::{UNDECODABLE, describe_oid, format_ip, hex_colons, join_or_none}; -/// Decodes a single extension into a renderable summary. -/// -/// Each arm matches the extension's OID against the [`AssociatedOid::OID`] of -/// the very type used to decode it, so the OID and its decoder can never drift -/// apart. `ObjectIdentifier` isn't usable in `match` patterns, hence the -/// `if`/`else if` chain. +/// 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; @@ -102,7 +91,6 @@ fn aki_summary(value: &[u8]) -> String { } } -/// Renders a `GeneralName` for the SAN summary. fn general_name(name: &GeneralName) -> String { match name { GeneralName::DnsName(s) => format!("DNS:{s}"), diff --git a/certkit-cli/src/report/fmt.rs b/certkit-cli/src/report/fmt.rs index 0a664e4..c5b731d 100644 --- a/certkit-cli/src/report/fmt.rs +++ b/certkit-cli/src/report/fmt.rs @@ -1,15 +1,8 @@ -//! Low-level formatting primitives shared by the report renderers. -//! -//! These turn raw bytes, OIDs, and strings into the human- and JSON-friendly -//! fragments that [`super`] and [`super::extensions`] assemble into output. - use const_oid::ObjectIdentifier; use const_oid::db::{rfc5280, rfc5912, rfc8410}; -/// Placeholder shown when an extension's value cannot be DER-decoded. pub(super) const UNDECODABLE: &str = "(undecodable)"; -/// Lowercase hex with colon separators, e.g. `9f:86:d0`. pub(super) fn hex_colons(bytes: &[u8]) -> String { bytes .iter() @@ -18,7 +11,6 @@ pub(super) fn hex_colons(bytes: &[u8]) -> String { .join(":") } -/// Formats raw IP-address octets: dotted-quad for v4, colon-hex for v6. pub(super) fn format_ip(octets: &[u8]) -> String { match octets.len() { 4 => octets @@ -35,12 +27,8 @@ pub(super) fn format_ip(octets: &[u8]) -> String { } } -/// Maps an OID to a friendly name. -/// -/// Known Extended Key Usage purposes and signature algorithms get a curated -/// short name; the OID identity comes from const-oid's named constants rather -/// than dotted-string literals. For anything else, we fall back to const-oid's -/// name database (e.g. `id-ecPublicKey`), and finally to the bare OID. +/// 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" @@ -78,7 +66,6 @@ pub(super) fn describe_oid(oid: ObjectIdentifier) -> String { name.to_string() } -/// Joins parts with commas, or reports `(none)` when empty. pub(super) fn join_or_none>(parts: &[S]) -> String { if parts.is_empty() { "(none)".to_string() @@ -90,22 +77,3 @@ pub(super) fn join_or_none>(parts: &[S]) -> String { .join(", ") } } - -/// Encodes a string as a JSON string literal (quotes + minimal escaping). -pub(super) fn json_string(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } - } - out.push('"'); - out -} diff --git a/certkit-cli/src/report/mod.rs b/certkit-cli/src/report/mod.rs index a036cd1..3b81dc3 100644 --- a/certkit-cli/src/report/mod.rs +++ b/certkit-cli/src/report/mod.rs @@ -1,13 +1,8 @@ -//! Rendering a parsed certificate for the `cert_info` subcommand. -//! -//! [`CertReport`] is a flat, render-ready view of the fields `cert_info` -//! reports. [`CertReport::from_cert`] decodes an [`X509Certificate`] into it -//! (delegating extension decoding to [`extensions`] and byte/OID formatting to -//! [`fmt`]), and [`CertReport::to_text`]/[`CertReport::to_json`] render it. - mod extensions; mod fmt; +use anyhow::Result; +use serde::Serialize; use sha2::{Digest, Sha256}; use time::OffsetDateTime; use x509_cert::Certificate as X509Certificate; @@ -15,11 +10,10 @@ use x509_cert::Certificate as X509Certificate; use certkit::cert::Certificate; use certkit::key::PublicKey; -use crate::Result; use extensions::describe_extension; -use fmt::{describe_oid, hex_colons, json_string}; +use fmt::{describe_oid, hex_colons}; -/// A decoded extension, ready to render in either output format. +#[derive(Serialize)] struct ExtReport { oid: String, name: &'static str, @@ -27,7 +21,7 @@ struct ExtReport { summary: String, } -/// The fields of a certificate that `cert_info` reports. +#[derive(Serialize)] pub struct CertReport { subject: String, issuer: String, @@ -127,49 +121,12 @@ impl CertReport { } pub fn to_json(&self) -> String { - let fingerprint = match &self.fingerprint_sha256 { - Some(fp) => json_string(fp), - None => "null".to_string(), - }; - let extensions: Vec = self - .extensions - .iter() - .map(|ext| { - format!( - "{{\"oid\":{},\"name\":{},\"critical\":{},\"summary\":{}}}", - json_string(&ext.oid), - json_string(ext.name), - ext.critical, - json_string(&ext.summary), - ) - }) - .collect(); - - format!( - concat!( - "{{\"subject\":{},\"issuer\":{},\"serial\":{},", - "\"not_before\":{},\"not_after\":{},\"expired\":{},", - "\"not_yet_valid\":{},\"days_remaining\":{},\"public_key\":{},", - "\"signature_algorithm\":{},\"fingerprint_sha256\":{},", - "\"extensions\":[{}]}}\n" - ), - json_string(&self.subject), - json_string(&self.issuer), - json_string(&self.serial), - json_string(&self.not_before), - json_string(&self.not_after), - self.expired, - self.not_yet_valid, - self.days_remaining, - json_string(&self.public_key), - json_string(&self.signature_algorithm), - fingerprint, - extensions.join(","), - ) + let mut out = serde_json::to_string(self).expect("CertReport is always serializable"); + out.push('\n'); + out } } -/// Names the subject's public key algorithm (and size, for RSA). fn describe_public_key(spki: &x509_cert::spki::SubjectPublicKeyInfoOwned) -> String { match PublicKey::from_x509spki(spki) { Ok(PublicKey::Rsa(key)) => { diff --git a/certkit-cli/tests/chain.rs b/certkit-cli/tests/chain.rs index e2af702..f124571 100644 --- a/certkit-cli/tests/chain.rs +++ b/certkit-cli/tests/chain.rs @@ -8,7 +8,7 @@ //! and field checks. The mixed-algorithm case additionally proves a CA of one //! algorithm can sign a subject of another. -mod common; +pub mod common; use std::process::Command; diff --git a/certkit-cli/tests/inspect.rs b/certkit-cli/tests/inspect.rs index 0c5dcec..363b01a 100644 --- a/certkit-cli/tests/inspect.rs +++ b/certkit-cli/tests/inspect.rs @@ -2,24 +2,15 @@ //! `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; -/// A `Command` for the `certkit` binary under test. -/// -/// Defaults the binary's logging to `error` for quiet output, while still -/// honoring an explicit `RUST_LOG` so `RUST_LOG=debug cargo test` surfaces logs. -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 -} - /// 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"); diff --git a/certkit-cli/tests/keygen.rs b/certkit-cli/tests/keygen.rs index 2ea0a3f..cf58338 100644 --- a/certkit-cli/tests/keygen.rs +++ b/certkit-cli/tests/keygen.rs @@ -2,19 +2,9 @@ //! combinations succeed, and mismatched or unparseable `--params` are rejected //! with a helpful message. -use std::process::Command; +pub mod common; -/// A `Command` for the `certkit` binary under test. -/// -/// Defaults the binary's logging to `error` for quiet output, while still -/// honoring an explicit `RUST_LOG` so `RUST_LOG=debug cargo test` surfaces logs. -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 -} +use common::certkit; /// Runs `keygen `, asserts it failed, and returns its stderr. fn keygen_fails(args: &[&str]) -> String { From 6f9c2480f45eb050ea52fa0577c0de4ad9725e2e Mon Sep 17 00:00:00 2001 From: Nick Cardin Date: Fri, 19 Jun 2026 21:27:45 -0400 Subject: [PATCH 3/5] stdin --- certkit-cli/src/cmd/inspect.rs | 11 +++-------- certkit-cli/src/io.rs | 15 +++++++-------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/certkit-cli/src/cmd/inspect.rs b/certkit-cli/src/cmd/inspect.rs index bb2cbd7..8afa17c 100644 --- a/certkit-cli/src/cmd/inspect.rs +++ b/certkit-cli/src/cmd/inspect.rs @@ -11,8 +11,8 @@ use crate::report::CertReport; #[derive(Args)] pub struct InspectOpt { - /// Certificate to read (PEM or DER, auto-detected). Omit or `-` for stdin. - pub input: Option, + /// 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, @@ -23,12 +23,7 @@ pub struct InspectOpt { impl InspectOpt { pub fn execute(&self) -> Result<()> { - let source = self - .input - .as_deref() - .filter(|p| p.as_os_str() != "-") - .map_or_else(|| "stdin".to_string(), |p| p.display().to_string()); - log::debug!("inspecting certificate from {source}"); + log::debug!("inspecting certificate from {}", self.input.display()); let bytes = read_cert_input(&self.input)?; let cert = Certificate::from_bytes(&bytes)?; diff --git a/certkit-cli/src/io.rs b/certkit-cli/src/io.rs index 18f84cb..9a6a943 100644 --- a/certkit-cli/src/io.rs +++ b/certkit-cli/src/io.rs @@ -8,14 +8,13 @@ use certkit::key::KeyPair; use crate::args::{CertFormat, CertOptArgs}; -pub fn read_cert_input(path: &Option) -> Result> { - match path.as_deref().filter(|p| p.as_os_str() != "-") { - Some(path) => Ok(fs::read(path)?), - None => { - let mut buf = Vec::new(); - std::io::stdin().lock().read_to_end(&mut buf)?; - Ok(buf) - } +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)?) } } From 871dbfc4c23e52d789b6f2f1dff21b497f25a0e7 Mon Sep 17 00:00:00 2001 From: Nick Cardin Date: Fri, 19 Jun 2026 21:30:41 -0400 Subject: [PATCH 4/5] msrv bump --- .github/workflows/rust.yml | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 54292ac..2dfb0a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2024" -rust-version = "1.85" +rust-version = "1.88" license = "MIT OR Apache-2.0" repository = "https://github.com/nacardin/certkit.git" homepage = "https://github.com/nacardin/certkit" From 8c3d025900bd80822f3eb52ae0b115aa56cabbd2 Mon Sep 17 00:00:00 2001 From: Nick Cardin Date: Sat, 20 Jun 2026 19:06:18 -0400 Subject: [PATCH 5/5] Clippy --- src/cert/params.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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)?); } }