Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 38 additions & 15 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <nick@cardin.email>"]

[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 <nick@cardin.email>"]
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"
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions certkit-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"

58 changes: 58 additions & 0 deletions certkit-cli/README.md
Original file line number Diff line number Diff line change
@@ -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 <command> --help` for the full set of options.
145 changes: 145 additions & 0 deletions certkit-cli/src/args.rs
Original file line number Diff line number Diff line change
@@ -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<Self, Self::Err> {
if let Ok(bits) = s.parse::<u32>() {
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<EkuOpt> 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<String>,
/// Subject state or province (ST).
#[arg(long)]
pub state: Option<String>,
/// Subject locality (L).
#[arg(long)]
pub locality: Option<String>,
/// Subject organization (O).
#[arg(long)]
pub organization: Option<String>,
/// Subject organizational unit (OU).
#[arg(long)]
pub organization_unit: Option<String>,
}

#[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<KeyParams>,
/// Use an existing private key (PKCS#8 PEM) instead of generating one.
#[arg(long)]
pub key: Option<PathBuf>,
/// Write a freshly generated private key here instead of stdout.
#[arg(long)]
pub key_out: Option<PathBuf>,
}

#[derive(Args)]
pub struct CertOptArgs {
/// DNS Subject Alternative Name (repeatable).
#[arg(long)]
pub dns: Vec<String>,
/// Email (rfc822) Subject Alternative Name (repeatable).
#[arg(long)]
pub email: Vec<String>,
/// Extended Key Usage purpose (repeatable).
#[arg(long = "eku", value_enum)]
pub eku: Vec<EkuOpt>,
/// 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<PathBuf>,
}
42 changes: 42 additions & 0 deletions certkit-cli/src/cmd/inspect.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
}
Loading