diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a3cb231..5e406e9 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,4 +1,4 @@ -name: Rust +name: CI on: push: @@ -8,12 +8,63 @@ on: env: CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" jobs: - build: + test: + name: Test runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install botan CLI + run: sudo apt-get update && sudo apt-get install -y botan + - name: Test + run: cargo test --workspace --all-features + + feature-gate: + name: Feature Gate Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Check feature combinations + run: ./scripts/compile_feature_combinations.sh + lint: + name: Format & Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: rustfmt + run: cargo fmt --all --check + - name: clippy + run: cargo clippy --workspace --all-features --all-targets -- -D warnings + + docs: + name: Docs + runs-on: ubuntu-latest + env: + RUSTDOCFLAGS: "-D warnings" + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: cargo doc + run: cargo doc --all-features --no-deps + + msrv: + name: MSRV (1.85) + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Check - run: cargo check + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@1.85.0 + - uses: Swatinem/rust-cache@v2 + - name: cargo check + run: cargo check --all-features diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3f2e2bd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,76 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] + +This release focuses on correctness, security, and returning `Result` instead of panicking. + +### Fixed + +- DER-encode ECDSA signatures instead of using fixed-width format. +- Use and declare matching SHA-384 / SHA-512 signatures for P-384 and P-521 curves. +- Build Distinguished Names structurally to prevent RDN injection and panics. +- Set digital signature key usage correctly for non-RSA algorithms. +- Encode IP and email SANs using IPAddress and rfc822Name types instead of DNSName. +- Fix import of RSA private keys in PKCS#1 DER format. +- Return errors instead of panicking for invalid serial numbers or far-future validity dates. +- Fix panic when parsing certificates without extensions. + +### Added + +- Add certificate parsing functions from DER, PEM, or auto-detected bytes. +- Add `Certificate::fingerprint` to compute SHA-256 digests. +- Add Subject Key Identifier and Authority Key Identifier extensions to issued certificates. +- Add `max_path_length` to `CertificateParams` for path constraints. +- Add `KeyPair::encode_private_key_der` for PKCS#8 DER export. +- Add `KeyType` enum and helper methods for key algorithm inspection. +- Add validity bounds, duration, and remaining helper methods. +- Add `Display` implementations for key pairs, types, and signature algorithms. +- Add light log instrumentation for key and certificate operations. +- Add an integration test verifying end-to-end TLS echo round-trips. +- Expand CI checks with clippy, rustfmt, docs, and MSRV validation. + +### Changed + +- Rename and restructure public library APIs to return `Result` and improve parameter structures. +- Use random 20-byte CSPRNG serial numbers by default. +- Prevent RSA key generation under 2048 bits and strip private keys from debug output. +- Replace OpenSSL and Botan test dependencies with pure Rust integration tests and reduce dependencies. + +## [0.1.2] + +### Added + +- Add Cargo features for individual cryptographic algorithms. +- Raise a compile error if no cryptographic algorithm features are enabled. +- Add a script and CI step to build different feature combinations. + +### Changed + +- Change P-256 public key DER serialization to use SEC1 point encoding. + +## [0.1.1] + +### Fixed + +- Correctly propagate the `is_ca` flag to basic constraints in issued certificates. +- Fix signature algorithm OID encoding for issued certificates. +- Fix test execution under non-English locales and newer OpenSSL versions. + +### Added + +- Add `KeyPair::encode_private_key_pem` to export private keys in PEM format. +- Add `Certificate::new_self_signed_with_expiration` for custom validity dates. + +## [0.1.0] + +### Added + +- Initial release implementing core X.509 certificate and key pair generation. +- Support key generation and management for RSA, ECDSA (P-256/P-384/P-521), and Ed25519. +- Support PEM/DER encoding for certificates and public/private keys. +- Add basic certificate extension support including Basic Constraints, Key Usage, and SAN. diff --git a/Cargo.toml b/Cargo.toml index 9f90b39..a2730da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "certkit" -version = "0.1.2" +version = "0.2.0" edition = "2024" +rust-version = "1.85" license = "MIT OR Apache-2.0" -description = "A pure Rust library for X.509 certificate management, creation, and validation, supporting RSA, ECDSA, and Ed25519 keys, with no OpenSSL or ring dependencies." +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" documentation = "https://docs.rs/certkit" @@ -13,33 +14,29 @@ categories = ["cryptography", "authentication"] authors = ["Nick Cardin "] [features] -default = ["rsa", "p256", "p384", "p521","ed25519"] -p521 = ["p384", "dep:p521", "ecdsa"] #For some reason p521 does not compile without p384... -ed25519 = ["ed25519-dalek"] +default = ["rsa", "p256", "p384", "p521", "ed25519"] +ed25519 = ["dep:ed25519-dalek"] [dependencies] bon = "3" const-oid = { version = "0.9.6", features = ["db"] } -rsa = { version = "0.9", optional = true } +der = "0.7" +ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8", "pem"], optional = true } +log = "0.4" 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 } -ecdsa = { version = "0.16", features = ["verifying"], optional = true } -ed25519-dalek = { version = "2", features = ["rand_core", "pkcs8", "pem"], optional = true} -sha2 = { version = "0.10", default-features = false, features = ["oid"] } -rand_core = { version = "0.6", features = ["getrandom"]} -der = "0.7" -time = "0.3" pem = "3" -x509-cert = "0.2.5" -pkcs8 = { version = "0.10.2", features = ["alloc", "pem"] } -rand = "0.9.1" -base64 = "0.22.1" +pkcs8 = { version = "0.10", features = ["alloc", "pem"] } +rand_core = { version = "0.6", features = ["getrandom"] } +rsa = { version = "0.9", optional = true } sha1 = "0.10" -thiserror = "1.0" -regex = "1.7" +sha2 = { version = "0.10", default-features = false, features = ["oid"] } +thiserror = "2" +time = "0.3" +x509-cert = "0.2" [dev-dependencies] -openssl = { version = "0.10" } -botan = { version = "0.11", features = ["vendored"] } +env_logger = "0.11" +rustls = "0.23" diff --git a/README.md b/README.md index ca04e6e..8395edc 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ A high-level Rust library providing abstractions over certificates and keys. Thi - Create intermediate CAs for certificate hierarchies - Support for multiple key types: - RSA - - ECDSA (P-256) + - ECDSA (P-256, P-384, P-521) - Ed25519 - PEM and DER format support - Modern Rust implementation with strong type safety -- Zero-copy parsing and serialization with `der` crate +- Type-safe parsing and serialization with `der` crate ## Usage @@ -21,14 +21,57 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -certkit = "0.1.0" +certkit = "0.2" ``` +## Cargo features + +Each cryptographic algorithm is behind its own feature. All are enabled by default, so the default build is unchanged: + +| Feature | Algorithm | Default | +|-----------|------------------|---------| +| `rsa` | RSA | yes | +| `p256` | ECDSA P-256 | yes | +| `p384` | ECDSA P-384 | yes | +| `p521` | ECDSA P-521 | yes | +| `ed25519` | Ed25519 | yes | + +To pull in only the algorithms you need, disable the defaults and opt back in. For example, an ECDSA-only build that drops RSA (and its `num-bigint-dig` / `libm` dependency tree): + +```toml +[dependencies] +certkit = { version = "0.2", default-features = false, features = ["p256", "p384"] } +``` + +At least one algorithm feature must be enabled; building with none is a compile error. + +## Examples + +[`tests/tls_echo.rs`](tests/tls_echo.rs) is a complete, runnable example that exercises the full PKI workflow: + +1. Generate a **root CA** (self-signed) +2. Issue an **intermediate CA** signed by the root +3. Issue **server** and **client** end-entity certificates from the intermediate +4. Stand up an **mTLS echo server** with `rustls` and verify a successful round-trip + +Run it with: + +```sh +cargo test mtls_echo +``` + +## Key formats + +| Standard | Supported | Notes | +|----------|-----------|-------| +| PKCS #1 | RSA only | Encoding/decoding RSA public and private keys; RSASSA-PKCS1-v1_5 signatures with SHA-256 | +| PKCS #8 | ✅ All | Primary private-key format for every algorithm (RSA, ECDSA, Ed25519). PEM and DER import/export | + ## Dependencies - `x509-cert`: X.509 certificate handling - `der`: ASN.1 DER encoding/decoding -- `pkcs8`: Private key cryptography standard +- `pkcs8`: Public-Key Cryptography Standards #8 - `rsa`, `p256`, `ed25519-dalek`: Cryptographic algorithms - `time`: Time handling for certificate validity - `pem`: PEM format encoding/decoding diff --git a/compile_feature_combinations.sh b/compile_feature_combinations.sh deleted file mode 100755 index 7bfcf5c..0000000 --- a/compile_feature_combinations.sh +++ /dev/null @@ -1,18 +0,0 @@ -#/usr/bin/env bash -cargo build - -cargo build --no-default-features --features rsa -cargo build --no-default-features --features p256 -cargo build --no-default-features --features p384 -cargo build --no-default-features --features p521 -cargo build --no-default-features --features ed25519 - -cargo build --no-default-features --features rsa,p256 -cargo build --no-default-features --features rsa,ed25519 -cargo build --no-default-features --features p256,ed25519 -cargo build --no-default-features --features p521,p256 -cargo build --no-default-features --features rsa,p521 -cargo build --no-default-features --features rsa,ed25519 -cargo build --no-default-features --features p521,ed25519 - - diff --git a/scripts/compile_feature_combinations.sh b/scripts/compile_feature_combinations.sh new file mode 100755 index 0000000..d7b607f --- /dev/null +++ b/scripts/compile_feature_combinations.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +cargo check + +cargo check --no-default-features --features rsa +cargo check --no-default-features --features p256 +cargo check --no-default-features --features p384 +cargo check --no-default-features --features p521 +cargo check --no-default-features --features ed25519 + +cargo check --no-default-features --features rsa,p256 +cargo check --no-default-features --features rsa,ed25519 +cargo check --no-default-features --features p256,ed25519 +cargo check --no-default-features --features p521,p256 +cargo check --no-default-features --features rsa,p521 +cargo check --no-default-features --features p384,p521 +cargo check --no-default-features --features p521,ed25519 + +echo "All feature combinations checked successfully." diff --git a/src/cert/extensions.rs b/src/cert/extensions.rs index 6d56a7e..b6ab08c 100644 --- a/src/cert/extensions.rs +++ b/src/cert/extensions.rs @@ -1,3 +1,5 @@ +//! X.509 certificate extensions. + use const_oid::AssociatedOid; use der::{ Decode, Encode, @@ -14,10 +16,13 @@ use x509_cert::ext::pkix::name::GeneralName; /// ``` /// use certkit::cert::extensions::SubjectAltName; /// use crate::certkit::cert::extensions::ToAndFromX509Extension; -/// let san = SubjectAltName { names: vec!["example.com".to_string()] }; +/// let san = SubjectAltName { +/// dns_names: vec!["example.com".to_string()], +/// ..Default::default() +/// }; /// let encoded = san.to_x509_extension_value().unwrap(); /// let decoded = SubjectAltName::from_x509_extension_value(&encoded).unwrap(); -/// assert_eq!(san.names, decoded.names); +/// assert_eq!(san.dns_names, decoded.dns_names); /// ``` pub trait ToAndFromX509Extension { /// The Object Identifier (OID) for the extension. @@ -35,45 +40,82 @@ pub trait ToAndFromX509Extension { /// Represents the Subject Alternative Name (SAN) extension. /// /// This extension specifies additional identities for the subject of the certificate. -/// -/// # Fields -/// * `names` - A list of DNS names. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct SubjectAltName { - pub names: Vec, + pub dns_names: Vec, + pub ip_addresses: Vec, + pub email_addresses: Vec, } impl ToAndFromX509Extension for SubjectAltName { const OID: ObjectIdentifier = x509_cert::ext::pkix::SubjectAltName::OID; fn to_x509_extension_value(&self) -> Result, CertKitError> { - let san = x509_cert::ext::pkix::SubjectAltName( - self.names - .iter() - .map(|name| { - Ia5String::try_from(name.clone()) - .map(GeneralName::DnsName) - .map_err(|e| CertKitError::InvalidInput(e.to_string())) - }) - .collect::, _>>()?, - ); + let mut names: Vec = Vec::new(); + + for dns in &self.dns_names { + let ia5 = Ia5String::try_from(dns.clone()) + .map_err(|e| CertKitError::InvalidInput(format!("invalid DNS SAN '{dns}': {e}")))?; + names.push(GeneralName::DnsName(ia5)); + } + + for ip in &self.ip_addresses { + let octets: Vec = match ip { + std::net::IpAddr::V4(v4) => v4.octets().to_vec(), + std::net::IpAddr::V6(v6) => v6.octets().to_vec(), + }; + let os = OctetString::new(octets) + .map_err(|e| CertKitError::EncodingError(format!("invalid IP SAN: {e}")))?; + names.push(GeneralName::IpAddress(os)); + } + + for email in &self.email_addresses { + let ia5 = Ia5String::try_from(email.clone()).map_err(|e| { + CertKitError::InvalidInput(format!("invalid email SAN '{email}': {e}")) + })?; + names.push(GeneralName::Rfc822Name(ia5)); + } + let san = x509_cert::ext::pkix::SubjectAltName(names); Ok(san.to_der()?) } fn from_x509_extension_value(extension: &[u8]) -> Result { let san = x509_cert::ext::pkix::SubjectAltName::from_der(extension)?; - let names = san - .0 - .iter() - .map(|name| match name { - GeneralName::DnsName(dns) => Ok(dns.to_string()), - _ => Err(CertKitError::InvalidInput( - "Unsupported general name type".to_string(), - )), - }) - .collect::, _>>()?; - Ok(Self { names }) + let mut out = SubjectAltName::default(); + + for name in san.0.iter() { + match name { + GeneralName::DnsName(dns) => out.dns_names.push(dns.as_str().to_string()), + GeneralName::Rfc822Name(email) => { + out.email_addresses.push(email.as_str().to_string()) + } + GeneralName::IpAddress(os) => { + let bytes = os.as_bytes(); + let ip = match bytes.len() { + 4 => std::net::IpAddr::V4(std::net::Ipv4Addr::new( + bytes[0], bytes[1], bytes[2], bytes[3], + )), + 16 => { + let mut octets = [0u8; 16]; + octets.copy_from_slice(bytes); + std::net::IpAddr::V6(std::net::Ipv6Addr::from(octets)) + } + n => { + return Err(CertKitError::DecodingError(format!( + "invalid IP address length in SAN: {n} bytes" + ))); + } + }; + out.ip_addresses.push(ip); + } + other => { + log::warn!("ignoring unsupported GeneralName variant in SAN: {other:?}"); + } + } + } + + Ok(out) } } @@ -83,11 +125,12 @@ impl ToAndFromX509Extension for SubjectAltName { /// /// # Fields /// * `is_ca` - Indicates if the certificate is a CA. -/// * `max_path_length` - The maximum number of intermediate CAs allowed. +/// * `max_path_length` - The maximum number of intermediate CAs allowed below +/// this one. `None` means unconstrained. #[derive(Default)] pub struct BasicConstraints { pub is_ca: bool, - pub max_path_length: Option, + pub max_path_length: Option, } impl ToAndFromX509Extension for BasicConstraints { @@ -96,7 +139,7 @@ impl ToAndFromX509Extension for BasicConstraints { fn to_x509_extension_value(&self) -> Result, CertKitError> { let bc = x509_cert::ext::pkix::BasicConstraints { ca: self.is_ca, - path_len_constraint: self.max_path_length.map(|v| v as u8), + path_len_constraint: self.max_path_length, }; Ok(bc.to_der()?) @@ -106,7 +149,7 @@ impl ToAndFromX509Extension for BasicConstraints { let bc = x509_cert::ext::pkix::BasicConstraints::from_der(der_bytes)?; Ok(Self { is_ca: bc.ca, - max_path_length: bc.path_len_constraint.map(|v| v as u32), + max_path_length: bc.path_len_constraint, }) } } @@ -214,7 +257,14 @@ impl From for ObjectIdentifier { /// Represents the Authority Key Identifier (AKI) extension. /// -/// This extension identifies the public key corresponding to the private key used to sign the certificate. +/// Identifies the public key of the CA that signed the certificate. +/// +/// The `authorityCertIssuer`/`authorityCertSerialNumber` fields are optional in +/// X.509 and faithfully preserved when decoding. When CertKit *issues* a +/// certificate it follows the common PKIX profile (RFC 5280 §4.2.1.1) and emits +/// only the key identifier, leaving those fields `None`; path building then +/// relies on matching this key identifier against the issuer's +/// [`SubjectKeyIdentifier`]. /// /// # Fields /// * `key_identifier` - The key identifier. @@ -222,24 +272,29 @@ impl From for ObjectIdentifier { /// * `authority_cert_serial_number` - The issuer's certificate serial number. pub struct AuthorityKeyIdentifier { pub key_identifier: Vec, - pub authority_cert_issuer: DistinguishedName, - pub authority_cert_serial_number: Vec, + pub authority_cert_issuer: Option, + pub authority_cert_serial_number: Option>, } impl ToAndFromX509Extension for AuthorityKeyIdentifier { const OID: ObjectIdentifier = x509_cert::ext::pkix::AuthorityKeyIdentifier::OID; fn to_x509_extension_value(&self) -> Result, CertKitError> { - let general_names = vec![GeneralName::DirectoryName( - self.authority_cert_issuer.as_x509_name(), - )]; + let authority_cert_issuer = match self.authority_cert_issuer.as_ref() { + Some(dn) => Some(vec![GeneralName::DirectoryName(dn.as_x509_name()?)]), + None => None, + }; + + let authority_cert_serial_number = self + .authority_cert_serial_number + .as_ref() + .map(|sn| x509_cert::serial_number::SerialNumber::new(sn.as_slice())) + .transpose()?; let aki = x509_cert::ext::pkix::AuthorityKeyIdentifier { key_identifier: Some(OctetString::new(self.key_identifier.as_slice())?), - authority_cert_issuer: Some(general_names), - authority_cert_serial_number: Some(x509_cert::serial_number::SerialNumber::new( - self.authority_cert_serial_number.as_slice(), - )?), + authority_cert_issuer, + authority_cert_serial_number, }; Ok(aki.to_der()?) @@ -254,10 +309,15 @@ impl ToAndFromX509Extension for AuthorityKeyIdentifier { .and_then(|names| { names.iter().find_map(|name| match name { GeneralName::DirectoryName(dn) => Some(DistinguishedName::from_x509_name(dn)), - _ => None, + other => { + log::warn!( + "ignoring non-DirectoryName in AKI authorityCertIssuer: {other:?}" + ); + None + } }) }) - .unwrap_or_default(); + .transpose()?; Ok(Self { key_identifier: aki @@ -267,8 +327,36 @@ impl ToAndFromX509Extension for AuthorityKeyIdentifier { authority_cert_issuer, authority_cert_serial_number: aki .authority_cert_serial_number - .map(|sn| sn.as_bytes().to_vec()) - .unwrap_or_default(), + .map(|sn| sn.as_bytes().to_vec()), + }) + } +} + +/// Represents the Subject Key Identifier (SKI) extension. +/// +/// # Fields +/// * `key_identifier` - Identifies the subject's public key (the SHA-1 digest of +/// the `subjectPublicKey`). +pub struct SubjectKeyIdentifier { + pub key_identifier: Vec, +} + +impl ToAndFromX509Extension for SubjectKeyIdentifier { + const OID: ObjectIdentifier = x509_cert::ext::pkix::SubjectKeyIdentifier::OID; + + fn to_x509_extension_value(&self) -> Result, CertKitError> { + let ski = x509_cert::ext::pkix::SubjectKeyIdentifier(OctetString::new( + self.key_identifier.as_slice(), + )?); + + Ok(ski.to_der()?) + } + + fn from_x509_extension_value(extension: &[u8]) -> Result { + let ski = x509_cert::ext::pkix::SubjectKeyIdentifier::from_der(extension)?; + + Ok(Self { + key_identifier: ski.0.as_bytes().to_vec(), }) } } @@ -277,6 +365,20 @@ impl ToAndFromX509Extension for AuthorityKeyIdentifier { mod tests { use super::*; + #[test] + fn subject_alt_name_round_trips_dns_ip_and_email() { + let original = SubjectAltName { + dns_names: vec!["example.com".to_string(), "www.example.com".to_string()], + ip_addresses: vec!["127.0.0.1".parse().unwrap(), "2001:db8::1".parse().unwrap()], + email_addresses: vec!["admin@example.com".to_string()], + }; + let encoded = original.to_x509_extension_value().unwrap(); + let decoded = SubjectAltName::from_x509_extension_value(&encoded).unwrap(); + assert_eq!(original.dns_names, decoded.dns_names); + assert_eq!(original.ip_addresses, decoded.ip_addresses); + assert_eq!(original.email_addresses, decoded.email_addresses); + } + #[test] fn test_basic_constraints_encoding_decoding() { let original = BasicConstraints { @@ -290,25 +392,41 @@ mod tests { } #[test] - fn test_authority_key_identifier_encoding_decoding() { + fn test_authority_key_identifier_key_id_only() { + // The form CertKit issues: key identifier only, optional fields absent. let original = AuthorityKeyIdentifier { key_identifier: vec![1, 2, 3, 4, 5], - authority_cert_issuer: DistinguishedName { + authority_cert_issuer: None, + authority_cert_serial_number: None, + }; + let encoded = original.to_x509_extension_value().unwrap(); + let decoded = AuthorityKeyIdentifier::from_x509_extension_value(&encoded).unwrap(); + assert_eq!(original.key_identifier, decoded.key_identifier); + assert!(decoded.authority_cert_issuer.is_none()); + assert!(decoded.authority_cert_serial_number.is_none()); + } + + #[test] + fn test_authority_key_identifier_with_issuer_and_serial() { + // A full AKI (e.g. parsed from a third-party cert) round-trips faithfully. + let original = AuthorityKeyIdentifier { + key_identifier: vec![1, 2, 3, 4, 5], + authority_cert_issuer: Some(DistinguishedName { common_name: "Test CA".to_string(), country: Some("US".to_string()), state: Some("California".to_string()), locality: Some("San Francisco".to_string()), organization: Some("Test Org".to_string()), organization_unit: Some("Test Unit".to_string()), - }, - authority_cert_serial_number: vec![6, 7, 8, 9, 10], + }), + authority_cert_serial_number: Some(vec![6, 7, 8, 9, 10]), }; let encoded = original.to_x509_extension_value().unwrap(); let decoded = AuthorityKeyIdentifier::from_x509_extension_value(&encoded).unwrap(); assert_eq!(original.key_identifier, decoded.key_identifier); assert_eq!( - original.authority_cert_issuer.common_name, - decoded.authority_cert_issuer.common_name + original.authority_cert_issuer.unwrap().common_name, + decoded.authority_cert_issuer.unwrap().common_name ); assert_eq!( original.authority_cert_serial_number, @@ -316,6 +434,16 @@ mod tests { ); } + #[test] + fn test_subject_key_identifier_encoding_decoding() { + let original = SubjectKeyIdentifier { + key_identifier: vec![1, 2, 3, 4, 5], + }; + let encoded = original.to_x509_extension_value().unwrap(); + let decoded = SubjectKeyIdentifier::from_x509_extension_value(&encoded).unwrap(); + assert_eq!(original.key_identifier, decoded.key_identifier); + } + #[test] fn test_key_usage_encoding_decoding() { let original = KeyUsage(KeyUsages::DigitalSignature | KeyUsages::KeyEncipherment); diff --git a/src/cert/mod.rs b/src/cert/mod.rs index f472036..6ddc09b 100644 --- a/src/cert/mod.rs +++ b/src/cert/mod.rs @@ -1,29 +1,18 @@ +//! Certificate creation, encoding/decoding, and management. + pub mod extensions; pub mod params; -use crate::error::CertKitError; -pub type Result = std::result::Result; +use crate::error::{CertKitError, Result}; use der::{Encode, EncodePem}; use extensions::ToAndFromX509Extension; -use params::{CertificationRequestInfo, ExtensionParam}; +use params::{CertificateParams, ExtensionParam}; use time::OffsetDateTime; use x509_cert::certificate::CertificateInner; use crate::issuer::Issuer; use crate::key::KeyPair; -// use crate::{key::KeyPair, pki::sign_data}; - -// #[derive(Debug, Clone, Builder)] -// pub struct TbsCertificate { -// pub serial_number: Vec, -// pub issuer_dn: DistinguishedName, -// pub validity: Validity, -// pub subject_dn: DistinguishedName, -// pub subject_public_key: PublicKey, -// pub extensions: Vec, -// } - /// Represents the supported signature algorithms for certificates. /// /// This enum defines the cryptographic signature algorithms that can be used @@ -53,7 +42,7 @@ use crate::key::KeyPair; /// // Would use SignatureAlgorithm::Sha256WithECDSA /// /// let ed25519_key = KeyPair::generate_ed25519(); -/// // Would use SignatureAlgorithm::Sha256WithEdDSA +/// // Would use SignatureAlgorithm::Ed25519 /// # Ok::<(), certkit::error::CertKitError>(()) /// ``` /// @@ -101,16 +90,16 @@ pub enum SignatureAlgorithm { /// - **Security**: ~256 bits (with P-521 curve) /// - **Compatibility**: Supported in modern systems Sha512WithECDSA, - /// SHA-256 with EdDSA (Ed25519). + /// Ed25519 (Edwards-curve Digital Signature Algorithm). /// - /// Uses Ed25519 signature scheme. Note that Ed25519 doesn't actually - /// use SHA-256 internally (it uses SHA-512 and other functions), but - /// this enum variant represents the Ed25519 algorithm identifier. + /// Uses the Ed25519 signature scheme as defined in RFC 8032. + /// Ed25519 uses its own internal hashing (SHA-512) and does not + /// require an external hash function. /// /// - **OID**: 1.3.101.112 (id-Ed25519) /// - **Security**: ~128 bits /// - **Compatibility**: Supported in newer systems (RFC 8410) - Sha256WithEdDSA, + Ed25519, } impl From for x509_cert::spki::AlgorithmIdentifierOwned { @@ -136,7 +125,7 @@ impl From for x509_cert::spki::AlgorithmIdentifierOwned { oid: const_oid::db::rfc5912::ECDSA_WITH_SHA_512, parameters: None, }, - SignatureAlgorithm::Sha256WithEdDSA => x509_cert::spki::AlgorithmIdentifierOwned { + SignatureAlgorithm::Ed25519 => x509_cert::spki::AlgorithmIdentifierOwned { oid: const_oid::db::rfc8410::ID_ED_25519, parameters: None, }, @@ -144,6 +133,18 @@ impl From for x509_cert::spki::AlgorithmIdentifierOwned { } } +impl std::fmt::Display for SignatureAlgorithm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Sha256WithRSA => write!(f, "SHA-256 with RSA"), + Self::Sha256WithECDSA => write!(f, "SHA-256 with ECDSA"), + Self::Sha384WithECDSA => write!(f, "SHA-384 with ECDSA"), + Self::Sha512WithECDSA => write!(f, "SHA-512 with ECDSA"), + Self::Ed25519 => write!(f, "Ed25519"), + } + } +} + /// Represents an X.509 certificate. /// /// This struct encapsulates a complete X.509 certificate and provides methods @@ -166,7 +167,7 @@ impl From for x509_cert::spki::AlgorithmIdentifierOwned { /// ```rust /// use certkit::{ /// key::KeyPair, -/// cert::{Certificate, params::{CertificationRequestInfo, DistinguishedName}}, +/// cert::{Certificate, params::{CertificateParams, DistinguishedName}}, /// }; /// /// // Generate a key pair @@ -180,13 +181,13 @@ impl From for x509_cert::spki::AlgorithmIdentifierOwned { /// .build(); /// /// // Create certificate request info -/// let cert_info = CertificationRequestInfo::builder() +/// let cert_info = CertificateParams::builder() /// .subject(subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) /// .build(); /// /// // Generate the self-signed certificate -/// let certificate = Certificate::new_self_signed(&cert_info, &key_pair); +/// let certificate = Certificate::new_self_signed(&cert_info, &key_pair)?; /// /// // Export to different formats /// let der_bytes = certificate.to_der()?; @@ -201,15 +202,15 @@ impl From for x509_cert::spki::AlgorithmIdentifierOwned { /// /// ```rust /// use certkit::cert::Certificate; -/// # use certkit::{key::KeyPair, cert::params::{CertificationRequestInfo, DistinguishedName}}; +/// # use certkit::{key::KeyPair, cert::params::{CertificateParams, DistinguishedName}}; /// # let key_pair = KeyPair::generate_ecdsa_p256(); /// # let subject = DistinguishedName::builder().common_name("test".to_string()).build(); -/// # let cert_info = CertificationRequestInfo::builder() +/// # let cert_info = CertificateParams::builder() /// # .subject(subject).subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)).build(); -/// # let certificate = Certificate::new_self_signed(&cert_info, &key_pair); +/// # let certificate = Certificate::new_self_signed(&cert_info, &key_pair)?; /// /// // Extract certificate information -/// let cert_info = certificate.to_cert_info()?; +/// let cert_info = certificate.params()?; /// println!("Subject: {}", cert_info.subject.common_name); /// println!("Is CA: {}", cert_info.is_ca); /// println!("Extensions: {}", cert_info.extensions.len()); @@ -217,11 +218,60 @@ impl From for x509_cert::spki::AlgorithmIdentifierOwned { /// ``` #[derive(Debug, Clone)] pub struct Certificate { - /// The inner representation of the certificate. - pub inner: CertificateInner, + inner: CertificateInner, } impl Certificate { + /// Creates a `Certificate` from the raw `x509_cert` inner representation. + pub(crate) fn from_inner(inner: CertificateInner) -> Self { + Self { inner } + } + + /// Returns a reference to the underlying `x509_cert` certificate. + #[cfg(test)] + pub(crate) fn inner(&self) -> &CertificateInner { + &self.inner + } + + /// Parses a certificate from DER-encoded bytes. + /// + /// # Errors + /// Returns `CertKitError::DecodingError` if the bytes are not a valid + /// DER-encoded X.509 certificate. + pub fn from_der(bytes: &[u8]) -> Result { + use der::Decode; + let inner = CertificateInner::from_der(bytes) + .map_err(|e| CertKitError::DecodingError(e.to_string()))?; + Ok(Self { inner }) + } + + /// Parses a certificate from a PEM-encoded string. + /// + /// # Errors + /// Returns `CertKitError::DecodingError` if the string is not a valid + /// PEM-encoded X.509 certificate. + pub fn from_pem(pem: &str) -> Result { + use der::DecodePem; + let inner = CertificateInner::from_pem(pem) + .map_err(|e| CertKitError::DecodingError(e.to_string()))?; + Ok(Self { inner }) + } + + /// Parses a certificate from PEM or DER-encoded bytes. + /// + /// # Errors + /// Returns `CertKitError::DecodingError` if the bytes are not a valid + /// X.509 certificate in either format. + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.starts_with(b"-----BEGIN") { + let pem = std::str::from_utf8(bytes) + .map_err(|e| CertKitError::DecodingError(format!("invalid UTF-8 in PEM: {e}")))?; + Self::from_pem(pem) + } else { + Self::from_der(bytes) + } + } + /// Encodes the certificate into DER format. /// /// Converts the certificate to Distinguished Encoding Rules (DER) format, @@ -238,17 +288,17 @@ impl Certificate { /// # Examples /// /// ```rust,no_run - /// use certkit::{key::KeyPair, cert::{Certificate, params::{CertificationRequestInfo, DistinguishedName}}}; + /// use certkit::{key::KeyPair, cert::{Certificate, params::{CertificateParams, DistinguishedName}}}; /// /// # fn main() -> Result<(), Box> { /// let key_pair = KeyPair::generate_rsa(2048)?; /// let subject = DistinguishedName::builder().common_name("test.com".to_string()).build(); - /// let cert_info = CertificationRequestInfo::builder() + /// let cert_info = CertificateParams::builder() /// .subject(subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) /// .build(); /// - /// let certificate = Certificate::new_self_signed(&cert_info, &key_pair); + /// let certificate = Certificate::new_self_signed(&cert_info, &key_pair)?; /// let der_bytes = certificate.to_der()?; /// /// // Save to file or transmit over network @@ -257,8 +307,8 @@ impl Certificate { /// # Ok(()) /// # } /// ``` - /// ``` pub fn to_der(&self) -> Result> { + log::trace!("encoding certificate to DER"); self.inner .to_der() .map_err(|e| CertKitError::EncodingError(e.to_string())) @@ -279,17 +329,17 @@ impl Certificate { /// # Examples /// /// ```rust,no_run - /// use certkit::{key::KeyPair, cert::{Certificate, params::{CertificationRequestInfo, DistinguishedName}}}; + /// use certkit::{key::KeyPair, cert::{Certificate, params::{CertificateParams, DistinguishedName}}}; /// /// # fn main() -> Result<(), Box> { /// let key_pair = KeyPair::generate_ed25519(); /// let subject = DistinguishedName::builder().common_name("server.example.com".to_string()).build(); - /// let cert_info = CertificationRequestInfo::builder() + /// let cert_info = CertificateParams::builder() /// .subject(subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) /// .build(); /// - /// let certificate = Certificate::new_self_signed(&cert_info, &key_pair); + /// let certificate = Certificate::new_self_signed(&cert_info, &key_pair)?; /// let pem_string = certificate.to_pem()?; /// /// println!("Certificate in PEM format:\n{}", pem_string); @@ -306,19 +356,63 @@ impl Certificate { /// - Base64-encoded DER data (64 characters per line) /// - "-----END CERTIFICATE-----" footer pub fn to_pem(&self) -> Result { + log::trace!("encoding certificate to PEM"); self.inner .to_pem(pkcs8::LineEnding::LF) .map_err(|e| CertKitError::EncodingError(e.to_string())) } - /// Extracts certificate information into a `CertificationRequestInfo` object. + /// Computes the SHA-256 fingerprint of the DER-encoded certificate. + /// + /// The fingerprint is the SHA-256 hash of the entire DER-encoded + /// certificate, which is the universally accepted way to uniquely + /// identify a certificate. This matches the output of + /// `openssl x509 -fingerprint -sha256`. + /// + /// # Returns + /// A 32-byte array containing the SHA-256 digest. + /// + /// # Errors + /// Returns `CertKitError::EncodingError` if the certificate cannot + /// be DER-encoded. + /// + /// # Examples + /// + /// ```rust + /// use certkit::{ + /// key::KeyPair, + /// cert::{Certificate, params::{CertificateParams, DistinguishedName}}, + /// }; + /// + /// let key = KeyPair::generate_ecdsa_p256(); + /// let subject = DistinguishedName::builder() + /// .common_name("test".to_string()) + /// .build(); + /// let params = CertificateParams::builder() + /// .subject(subject) + /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&key)) + /// .build(); + /// let cert = Certificate::new_self_signed(¶ms, &key).unwrap(); + /// + /// let fp = cert.fingerprint().unwrap(); + /// println!("SHA-256 fingerprint: {:02X?}", fp); + /// assert_eq!(fp.len(), 32); + /// ``` + pub fn fingerprint(&self) -> Result<[u8; 32]> { + use sha2::Digest; + let der = self.to_der()?; + let hash = sha2::Sha256::digest(&der); + Ok(hash.into()) + } + + /// Extracts certificate information into a `CertificateParams` object. /// /// Parses the certificate and extracts key information including the subject, /// public key, extensions, and CA status. This is useful for certificate /// analysis, validation, and creating derived certificates. /// /// # Returns - /// A `Result` containing the `CertificationRequestInfo` with extracted details, + /// A `Result` containing the `CertificateParams` with extracted details, /// or a `CertKitError` on failure. /// /// # Errors @@ -330,7 +424,7 @@ impl Certificate { /// # Examples /// /// ```rust,no_run - /// use certkit::{key::KeyPair, cert::{Certificate, params::{CertificationRequestInfo, DistinguishedName}}}; + /// use certkit::{key::KeyPair, cert::{Certificate, params::{CertificateParams, DistinguishedName}}}; /// /// # fn main() -> Result<(), certkit::error::CertKitError> { /// // Create a certificate @@ -340,16 +434,16 @@ impl Certificate { /// .organization("Example CA".to_string()) /// .build(); /// - /// let cert_info = CertificationRequestInfo::builder() + /// let cert_info = CertificateParams::builder() /// .subject(subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) /// .is_ca(true) /// .build(); /// - /// let certificate = Certificate::new_self_signed(&cert_info, &key_pair); + /// let certificate = Certificate::new_self_signed(&cert_info, &key_pair)?; /// /// // Extract information back from the certificate - /// let extracted_info = certificate.to_cert_info()?; + /// let extracted_info = certificate.params()?; /// println!("Subject CN: {}", extracted_info.subject.common_name); /// println!("Is CA: {}", extracted_info.is_ca); /// println!("Number of extensions: {}", extracted_info.extensions.len()); @@ -365,17 +459,17 @@ impl Certificate { /// - **CA Status**: Whether this is a CA certificate /// - **Key Usages**: Extended key usage extensions /// - **Extensions**: All X.509 extensions present in the certificate - pub fn to_cert_info(&self) -> Result { + pub fn params(&self) -> Result { let inner_tbs_cert = self.inner.tbs_certificate.clone(); - let subject = params::DistinguishedName::from_x509_name(&inner_tbs_cert.subject); + let subject = params::DistinguishedName::from_x509_name(&inner_tbs_cert.subject)?; let subject_public_key = crate::key::PublicKey::from_x509spki(&inner_tbs_cert.subject_public_key_info)?; let extensions: Vec = inner_tbs_cert .extensions - .unwrap() + .unwrap_or_default() .iter() .map(|ext| ExtensionParam { oid: ext.extn_id, @@ -398,25 +492,21 @@ impl Certificate { .next() .unwrap_or_default(); - let is_ca = extensions + let basic_constraints = extensions .iter() - .filter_map(|ext| { - if ext.oid == crate::cert::extensions::BasicConstraints::OID { - let basic_constraints: crate::cert::extensions::BasicConstraints = - ext.to_extension().unwrap_or_default(); - Some(basic_constraints.is_ca) - } else { - None - } + .find(|ext| ext.oid == crate::cert::extensions::BasicConstraints::OID) + .map(|ext| { + ext.to_extension::() + .unwrap_or_default() }) - .next() - .unwrap_or(false); + .unwrap_or_default(); - Ok(CertificationRequestInfo { + Ok(CertificateParams { subject: subject.clone(), subject_public_key, usages, - is_ca, + is_ca: basic_constraints.is_ca, + max_path_length: basic_constraints.max_path_length, extensions, }) } @@ -429,7 +519,7 @@ impl Certificate { /// /// # Certificate Properties /// - **Validity**: 365 days from creation time - /// - **Serial Number**: Fixed value of 1 + /// - **Serial Number**: 20-byte CSPRNG value (RFC 5280 §4.1.2.2 compliant) /// - **Version**: X.509 v3 /// - **Signature Algorithm**: Automatically selected based on key type /// @@ -449,7 +539,7 @@ impl Certificate { /// ```rust,no_run /// use certkit::{ /// key::KeyPair, - /// cert::{Certificate, params::{CertificationRequestInfo, DistinguishedName}}, + /// cert::{Certificate, params::{CertificateParams, DistinguishedName}}, /// }; /// /// # fn main() -> Result<(), certkit::error::CertKitError> { @@ -461,13 +551,13 @@ impl Certificate { /// .country("US".to_string()) /// .build(); /// - /// let cert_info = CertificationRequestInfo::builder() + /// let cert_info = CertificateParams::builder() /// .subject(subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) /// .is_ca(true) // Mark as CA certificate /// .build(); /// - /// let root_cert = Certificate::new_self_signed(&cert_info, &key_pair); + /// let root_cert = Certificate::new_self_signed(&cert_info, &key_pair)?; /// println!("Root CA certificate created"); /// # Ok(()) /// # } @@ -480,7 +570,7 @@ impl Certificate { /// key::KeyPair, /// cert::{ /// Certificate, - /// params::{CertificationRequestInfo, DistinguishedName, ExtensionParam}, + /// params::{CertificateParams, DistinguishedName, ExtensionParam}, /// extensions::{SubjectAltName, ToAndFromX509Extension}, /// }, /// }; @@ -490,20 +580,22 @@ impl Certificate { /// /// // Create Subject Alternative Name extension /// let san = SubjectAltName { - /// names: vec!["localhost".to_string(), "127.0.0.1".to_string()], + /// dns_names: vec!["localhost".to_string()], + /// ip_addresses: vec!["127.0.0.1".parse().unwrap()], + /// ..Default::default() /// }; /// /// let subject = DistinguishedName::builder() /// .common_name("localhost".to_string()) /// .build(); /// - /// let cert_info = CertificationRequestInfo::builder() + /// let cert_info = CertificateParams::builder() /// .subject(subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) - /// .extensions(vec![ExtensionParam::from_extension(san, false)]) + /// .extensions(vec![ExtensionParam::from_extension(san, false)?]) /// .build(); /// - /// let cert = Certificate::new_self_signed(&cert_info, &key_pair); + /// let cert = Certificate::new_self_signed(&cert_info, &key_pair)?; /// println!("Self-signed certificate with SAN created"); /// # Ok(()) /// # } @@ -514,7 +606,7 @@ impl Certificate { /// - **Development/testing**: Quick certificate generation for testing /// - **Internal services**: Certificates for internal-only applications /// - **Bootstrap certificates**: Initial certificates for certificate enrollment - pub fn new_self_signed(cert_info: &CertificationRequestInfo, key: &KeyPair) -> Self { + pub fn new_self_signed(cert_info: &CertificateParams, key: &KeyPair) -> Result { let now = OffsetDateTime::now_utc(); Self::new_self_signed_with_expiration(cert_info, key, now, now + time::Duration::days(365)) } @@ -526,7 +618,7 @@ impl Certificate { /// or for testing purposes. /// /// # Certificate Properties - /// - **Serial Number**: Fixed value of 1 + /// - **Serial Number**: 20-byte CSPRNG value (RFC 5280 §4.1.2.2 compliant) /// - **Version**: X.509 v3 /// - **Signature Algorithm**: Automatically selected based on key type /// @@ -546,7 +638,7 @@ impl Certificate { /// ```rust,no_run /// use certkit::{ /// key::KeyPair, - /// cert::{Certificate, params::{CertificationRequestInfo, DistinguishedName}}, + /// cert::{Certificate, params::{CertificateParams, DistinguishedName}}, /// }; /// /// # fn main() -> Result<(), certkit::error::CertKitError> { @@ -559,7 +651,7 @@ impl Certificate { /// .country("US".to_string()) /// .build(); /// - /// let cert_info = CertificationRequestInfo::builder() + /// let cert_info = CertificateParams::builder() /// .subject(subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) /// .is_ca(true) // Mark as CA certificate @@ -567,7 +659,7 @@ impl Certificate { /// /// let now = OffsetDateTime::now_utc(); /// - /// let root_cert = Certificate::new_self_signed_with_expiration(&cert_info, &key_pair, now, now + time::Duration::days(365)); + /// let root_cert = Certificate::new_self_signed_with_expiration(&cert_info, &key_pair, now, now + time::Duration::days(365))?; /// println!("Root CA certificate created"); /// # Ok(()) /// # } @@ -580,7 +672,7 @@ impl Certificate { /// key::KeyPair, /// cert::{ /// Certificate, - /// params::{CertificationRequestInfo, DistinguishedName, ExtensionParam}, + /// params::{CertificateParams, DistinguishedName, ExtensionParam}, /// extensions::{SubjectAltName, ToAndFromX509Extension}, /// }, /// }; @@ -591,22 +683,24 @@ impl Certificate { /// /// // Create Subject Alternative Name extension /// let san = SubjectAltName { - /// names: vec!["localhost".to_string(), "127.0.0.1".to_string()], + /// dns_names: vec!["localhost".to_string()], + /// ip_addresses: vec!["127.0.0.1".parse().unwrap()], + /// ..Default::default() /// }; /// /// let subject = DistinguishedName::builder() /// .common_name("localhost".to_string()) /// .build(); /// - /// let cert_info = CertificationRequestInfo::builder() + /// let cert_info = CertificateParams::builder() /// .subject(subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) - /// .extensions(vec![ExtensionParam::from_extension(san, false)]) + /// .extensions(vec![ExtensionParam::from_extension(san, false)?]) /// .build(); /// /// let now = OffsetDateTime::now_utc(); /// - /// let cert = Certificate::new_self_signed_with_expiration(&cert_info, &key_pair, now, now + time::Duration::days(365)); + /// let cert = Certificate::new_self_signed_with_expiration(&cert_info, &key_pair, now, now + time::Duration::days(365))?; /// println!("Self-signed certificate with SAN created"); /// # Ok(()) /// # } @@ -618,11 +712,15 @@ impl Certificate { /// - **Internal services**: Certificates for internal-only applications /// - **Bootstrap certificates**: Initial certificates for certificate enrollment pub fn new_self_signed_with_expiration( - cert_info: &CertificationRequestInfo, + cert_info: &CertificateParams, key: &KeyPair, not_before: OffsetDateTime, not_after: OffsetDateTime, - ) -> Self { + ) -> Result { + log::debug!( + "creating self-signed certificate for \"{}\"", + cert_info.subject.common_name + ); let subject_dn = cert_info.subject.clone(); // For self-signed certificates, the issuer is the same as the subject @@ -631,10 +729,7 @@ impl Certificate { key, }; - let validity = params::Validity { - not_before, - not_after, - }; + let validity = params::Validity::new(not_before, not_after)?; self_issuer.issue(cert_info, validity) } @@ -647,17 +742,13 @@ struct SelfIssuer<'a> { } impl Issuer for SelfIssuer<'_> { - fn issuer_name(&self) -> params::DistinguishedName { - self.name.clone() + fn issuer_name(&self) -> Result { + Ok(self.name.clone()) } fn signing_key(&self) -> &KeyPair { self.key } - - fn serial_number(&self) -> Vec { - vec![1] - } } /// A certificate paired with its corresponding private key. @@ -681,7 +772,7 @@ impl Issuer for SelfIssuer<'_> { /// ```rust /// use certkit::{ /// key::KeyPair, -/// cert::{Certificate, CertificateWithPrivateKey, params::{CertificationRequestInfo, DistinguishedName}}, +/// cert::{Certificate, CertificateWithPrivateKey, params::{CertificateParams, DistinguishedName}}, /// }; /// /// // Generate CA key pair @@ -693,19 +784,16 @@ impl Issuer for SelfIssuer<'_> { /// .organization("Example Corp".to_string()) /// .build(); /// -/// let ca_cert_info = CertificationRequestInfo::builder() +/// let ca_cert_info = CertificateParams::builder() /// .subject(ca_subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&ca_key)) /// .is_ca(true) /// .build(); /// -/// let ca_cert = Certificate::new_self_signed(&ca_cert_info, &ca_key); +/// let ca_cert = Certificate::new_self_signed(&ca_cert_info, &ca_key)?; /// /// // Combine certificate and private key -/// let ca_with_key = CertificateWithPrivateKey { -/// cert: ca_cert, -/// key: ca_key, -/// }; +/// let ca_with_key = CertificateWithPrivateKey::new(ca_cert, ca_key); /// /// println!("CA certificate with private key created"); /// # Ok::<(), certkit::error::CertKitError>(()) @@ -716,16 +804,16 @@ impl Issuer for SelfIssuer<'_> { /// ```rust /// use certkit::{ /// key::KeyPair, -/// cert::{Certificate, CertificateWithPrivateKey, params::{CertificationRequestInfo, DistinguishedName, Validity}}, +/// cert::{Certificate, CertificateWithPrivateKey, params::{CertificateParams, DistinguishedName, Validity}}, /// issuer::Issuer, /// }; /// /// # let ca_key = KeyPair::generate_ecdsa_p256(); /// # let ca_subject = DistinguishedName::builder().common_name("CA".to_string()).build(); -/// # let ca_cert_info = CertificationRequestInfo::builder() +/// # let ca_cert_info = CertificateParams::builder() /// .subject(ca_subject).subject_public_key(certkit::key::PublicKey::from_key_pair(&ca_key)).is_ca(true).build(); -/// # let ca_cert = Certificate::new_self_signed(&ca_cert_info, &ca_key); -/// # let ca_with_key = CertificateWithPrivateKey { cert: ca_cert, key: ca_key }; +/// # let ca_cert = Certificate::new_self_signed(&ca_cert_info, &ca_key)?; +/// # let ca_with_key = CertificateWithPrivateKey::new(ca_cert, ca_key); /// /// // Generate end-entity key pair /// let server_key = KeyPair::generate_rsa(2048)?; @@ -735,14 +823,14 @@ impl Issuer for SelfIssuer<'_> { /// .common_name("server.example.com".to_string()) /// .build(); /// -/// let server_cert_info = CertificationRequestInfo::builder() +/// let server_cert_info = CertificateParams::builder() /// .subject(server_subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&server_key)) /// .build(); /// /// // Issue the server certificate using the CA -/// let validity = Validity::for_days(365); -/// let server_cert = ca_with_key.issue(&server_cert_info, validity); +/// let validity = Validity::for_days(365)?; +/// let server_cert = ca_with_key.issue(&server_cert_info, validity)?; /// /// println!("Server certificate issued by CA"); /// # Ok::<(), certkit::error::CertKitError>(()) @@ -756,32 +844,41 @@ impl Issuer for SelfIssuer<'_> { /// - **Backup and Recovery**: Ensure secure backup of CA key material #[derive(Debug, Clone)] pub struct CertificateWithPrivateKey { - /// The X.509 certificate - pub cert: Certificate, - /// The private key corresponding to the public key in the certificate - pub key: crate::key::KeyPair, + cert: Certificate, + key: crate::key::KeyPair, +} + +impl CertificateWithPrivateKey { + /// Creates a new `CertificateWithPrivateKey` pairing a certificate with + /// its corresponding private key. + pub fn new(cert: Certificate, key: KeyPair) -> Self { + Self { cert, key } + } + + /// Returns a reference to the certificate. + pub fn cert(&self) -> &Certificate { + &self.cert + } + + /// Returns a reference to the private key. + pub fn key(&self) -> &KeyPair { + &self.key + } + + /// Consumes self and returns the certificate and key as a tuple. + pub fn into_parts(self) -> (Certificate, KeyPair) { + (self.cert, self.key) + } } impl Issuer for CertificateWithPrivateKey { - fn issuer_name(&self) -> params::DistinguishedName { + fn issuer_name(&self) -> Result { // The name of the issuer is the subject of the certificate - let cert_info = self - .cert - .to_cert_info() - .expect("Failed to extract cert info"); - cert_info.subject + let cert_info = self.cert.params()?; + Ok(cert_info.subject) } fn signing_key(&self) -> &KeyPair { &self.key } - - fn serial_number(&self) -> Vec { - self.cert - .inner - .tbs_certificate - .serial_number - .as_bytes() - .to_vec() - } } diff --git a/src/cert/params.rs b/src/cert/params.rs index b27df30..4326909 100644 --- a/src/cert/params.rs +++ b/src/cert/params.rs @@ -1,13 +1,19 @@ +//! Certificate parameters and builder types. + use bon::Builder; use const_oid::ObjectIdentifier; +use const_oid::db::rfc4519; +use log::warn; use time::Duration; use time::OffsetDateTime; -use x509_cert::name::RdnSequence; + +use crate::error::CertKitError; +use x509_cert::attr::AttributeTypeAndValue; +use x509_cert::name::{RdnSequence, RelativeDistinguishedName}; use super::extensions::ToAndFromX509Extension; pub use crate::cert::extensions::ExtendedKeyUsage; pub use crate::cert::extensions::ExtendedKeyUsageOption; -use crate::error::CertKitError; use crate::key::PublicKey; // use super::extensions::{Extension}; @@ -21,15 +27,18 @@ use crate::key::PublicKey; /// * `subject_public_key` - The public key of the certificate subject. /// * `usages` - A list of extended key usage options. /// * `is_ca` - Indicates if the certificate is a CA. +/// * `max_path_length` - For a CA, the maximum number of intermediate CAs that +/// may appear below it. Ignored when `is_ca` is `false`. /// * `extensions` - Additional X.509 extensions. #[derive(Clone, Debug, Builder)] -pub struct CertificationRequestInfo { +pub struct CertificateParams { pub subject: DistinguishedName, pub subject_public_key: PublicKey, #[builder(default)] pub usages: Vec, #[builder(default)] pub is_ca: bool, + pub max_path_length: Option, #[builder(default)] pub extensions: Vec, } @@ -55,87 +64,210 @@ pub struct DistinguishedName { pub organization_unit: Option, } +/// Builds a single-attribute RDN carrying `value` as a DER `UTF8String`. +fn dn_rdn(oid: ObjectIdentifier, value: &str) -> Result { + let any = der::Any::new(der::Tag::Utf8String, value.as_bytes()) + .map_err(|e| CertKitError::EncodingError(format!("DN attribute value: {e}")))?; + let atv = AttributeTypeAndValue { oid, value: any }; + let set = der::asn1::SetOfVec::try_from(vec![atv]) + .map_err(|e| CertKitError::EncodingError(format!("DN attribute set: {e}")))?; + Ok(RelativeDistinguishedName(set)) +} + impl DistinguishedName { /// Converts the distinguished name to an X.509-compatible format. /// - /// # Returns - /// An `x509_cert::name::DistinguishedName` object. - pub fn as_x509_name(&self) -> x509_cert::name::DistinguishedName { - use core::str::FromStr; - let rfc4514_name = format!( - "CN={},OU={},O={},L={},ST={},C={}", - self.common_name, - self.organization_unit.clone().unwrap_or_default(), - self.organization.clone().unwrap_or_default(), - self.locality.clone().unwrap_or_default(), - self.state.clone().unwrap_or_default(), - self.country.clone().unwrap_or_default() - ); - RdnSequence::from_str(&rfc4514_name).unwrap() + /// The RDN sequence is built structurally: each attribute value is placed in + /// the certificate verbatim as a `UTF8String`. + /// + /// Only attributes that are actually set are emitted. They are encoded in + /// conventional most-significant-first order (C, ST, L, O, OU, CN), which + /// RFC 4514 renders in reverse as `CN=...,...,C=...`. + /// + /// # Errors + /// Returns `CertKitError::EncodingError` only if an attribute value cannot be + /// encoded as a DER `UTF8String` This will never happen with a Rust `&str`. + pub fn as_x509_name(&self) -> Result { + let mut rdns: Vec = Vec::new(); + + let optional_attrs = [ + (rfc4519::C, &self.country), + (rfc4519::ST, &self.state), + (rfc4519::L, &self.locality), + (rfc4519::O, &self.organization), + (rfc4519::OU, &self.organization_unit), + ]; + + for (oid, value) in optional_attrs { + if let Some(value) = value { + if !value.is_empty() { + rdns.push(dn_rdn(oid, value)?); + } + } + } + + // Common Name is the most specific attribute, therefore it is encoded last. + rdns.push(dn_rdn(rfc4519::CN, &self.common_name)?); + + Ok(RdnSequence(rdns)) } /// Creates a `DistinguishedName` from an X.509-compatible format. /// + /// Parses all standard DN attributes: CN (2.5.4.3), OU (2.5.4.11), + /// O (2.5.4.10), L (2.5.4.7), ST (2.5.4.8), and C (2.5.4.6). + /// /// # Arguments /// * `x509dn` - An `x509_cert::name::DistinguishedName` object. /// - /// # Returns - /// A `DistinguishedName` object. - pub fn from_x509_name(x509dn: &x509_cert::name::DistinguishedName) -> Self { + /// # Errors + /// Returns `CertKitError::DecodingError` if the certificate is malformed + /// and an attribute value cannot be decoded as a string. + pub fn from_x509_name( + x509dn: &x509_cert::name::DistinguishedName, + ) -> Result { let mut common_name = String::new(); + let mut organization_unit = None; + let mut organization = None; + let mut locality = None; + let mut state = None; + let mut country = None; - // Extract the common name from subject if available for rdn in x509dn.0.iter() { for attr in rdn.0.iter() { - if attr.oid.to_string() == "2.5.4.3" { - // Common Name OID - if let Ok(s) = attr.value.decode_as::() { - common_name = s.to_string(); - } else { - panic!("Common name is not a PrintableString"); + // DN attributes may be encoded as Utf8String, PrintableString, + // or other ASN.1 string types depending on the issuer and + // attribute (e.g. Country is typically PrintableString per X.520). + let value = attr + .value + .decode_as::>() + .map(|s| s.as_str().to_owned()) + .or_else(|_| { + attr.value + .decode_as::>() + .map(|s| s.as_str().to_owned()) + }) + .or_else(|_| { + attr.value + .decode_as::>() + .map(|s| s.as_str().to_owned()) + }) + .map_err(|_| { + CertKitError::DecodingError(format!( + "DN attribute {} value cannot be decoded as a string", + attr.oid + )) + })?; + match attr.oid { + oid if oid == rfc4519::CN => common_name = value, + oid if oid == rfc4519::OU => organization_unit = Some(value), + oid if oid == rfc4519::O => organization = Some(value), + oid if oid == rfc4519::L => locality = Some(value), + oid if oid == rfc4519::ST => state = Some(value), + oid if oid == rfc4519::C => country = Some(value), + _ => { + warn!("Unknown DN attribute {} value {}", attr.oid, value); } } } } - DistinguishedName { + Ok(DistinguishedName { common_name, - organization_unit: None, - organization: None, - locality: None, - state: None, - country: None, - } + organization_unit, + organization, + locality, + state, + country, + }) } } /// Certificate validity period. /// /// This struct represents the `notBefore` and `notAfter` fields in a certificate. -/// -/// # Fields -/// * `not_before` - The start of the validity period. -/// * `not_after` - The end of the validity period. -#[derive(Clone, Debug)] +#[derive(Copy, Clone, Debug)] pub struct Validity { - pub not_before: OffsetDateTime, - pub not_after: OffsetDateTime, + not_before: x509_cert::time::Time, + not_after: x509_cert::time::Time, +} + +/// Encodes an [`OffsetDateTime`] as the correct X.509 `Time` variant per +/// RFC 5280 §4.1.2.5: `UTCTime` for years 1970–2049, `GeneralizedTime` for 2050+. +fn encode_x509_time(dt: OffsetDateTime) -> Result { + let sys_time: std::time::SystemTime = dt.into(); + // Try UTCTime first (covers 1970–2049 per the `der` crate's UtcTime bounds) + match der::asn1::UtcTime::from_system_time(sys_time) { + Ok(ut) => Ok(x509_cert::time::Time::UtcTime(ut)), + Err(_) => { + // If outside UTCTime range, use GeneralizedTime + let gt = der::asn1::GeneralizedTime::from_system_time(sys_time).map_err(|e| { + CertKitError::EncodingError(format!("timestamp out of GeneralizedTime range: {e}")) + })?; + Ok(x509_cert::time::Time::GeneralTime(gt)) + } + } } impl Validity { + /// Creates a validity period from explicit [`OffsetDateTime`] bounds. + /// + /// The timestamps are encoded per RFC 5280 §4.1.2.5 (UTCTime through + /// 2049, GeneralizedTime from 2050 onward). + /// + /// # Errors + /// Returns [`CertKitError::EncodingError`] if either timestamp cannot be + /// represented as an ASN.1 time value. + pub fn new( + not_before: OffsetDateTime, + not_after: OffsetDateTime, + ) -> Result { + Ok(Self { + not_before: encode_x509_time(not_before)?, + not_after: encode_x509_time(not_after)?, + }) + } + /// Creates a validity period starting now for the given number of days. /// /// # Arguments /// * `days` - The number of days for the validity period. /// - /// # Returns - /// A `Validity` object. - pub fn for_days(days: i64) -> Self { + /// # Errors + /// Returns [`CertKitError::EncodingError`] if the resulting timestamps + /// cannot be represented as ASN.1 time values (practically infallible + /// for real-world dates). + pub fn for_days(days: i64) -> Result { let now = OffsetDateTime::now_utc(); - Self { - not_before: now, - not_after: now + Duration::days(days), - } + Self::new(now, now + Duration::days(days)) + } + + /// Returns the start of the validity period, as a pre-encoded + /// [`x509_cert::time::Time`]. + pub fn not_before(&self) -> x509_cert::time::Time { + self.not_before + } + + /// Returns the end of the validity period, as a pre-encoded + /// [`x509_cert::time::Time`]. + pub fn not_after(&self) -> x509_cert::time::Time { + self.not_after + } + + /// Returns the total duration of the validity period. + pub fn duration(&self) -> std::time::Duration { + let nb: std::time::SystemTime = self.not_before.to_system_time(); + let na: std::time::SystemTime = self.not_after.to_system_time(); + na.duration_since(nb).unwrap_or_default() + } + + /// Returns the time remaining until the certificate expires, relative to now. + /// + /// Returns `None` if the certificate has already expired (i.e. `not_after` + /// is in the past). + pub fn remaining(&self) -> Option { + let na: std::time::SystemTime = self.not_after.to_system_time(); + na.duration_since(std::time::SystemTime::now()).ok() } } @@ -164,15 +296,16 @@ impl ExtensionParam { /// /// # Returns /// An `ExtensionParam` object. - pub fn from_extension(extension: E, critical: bool) -> Self { - let value = extension - .to_x509_extension_value() - .unwrap_or_else(|_| vec![]); - Self { + pub fn from_extension( + extension: E, + critical: bool, + ) -> Result { + let value = extension.to_x509_extension_value()?; + Ok(Self { oid: E::OID, critical, value, - } + }) } /// Decodes an `ExtensionParam` into a specific extension. @@ -183,3 +316,89 @@ impl ExtensionParam { E::from_x509_extension_value(&self.value) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn common_name_only_produces_single_rdn() { + let dn = DistinguishedName { + common_name: "leaf.example.com".to_string(), + ..Default::default() + }; + + let x509_name = dn.as_x509_name().unwrap(); + + // Exactly one RDN holding the CN, OU/O/L/ST/C attributes are left unset. + assert_eq!(x509_name.0.len(), 1, "expected a single RDN"); + let attrs: Vec<_> = x509_name.0.iter().flat_map(|rdn| rdn.0.iter()).collect(); + assert_eq!(attrs.len(), 1, "expected a single attribute"); + assert_eq!(attrs[0].oid, rfc4519::CN); + assert_eq!(x509_name.to_string(), "CN=leaf.example.com"); + + // And it round-trips back to the original common name with no other fields. + let round_tripped = DistinguishedName::from_x509_name(&x509_name).unwrap(); + assert_eq!(round_tripped.common_name, "leaf.example.com"); + assert!(round_tripped.organization_unit.is_none()); + assert!(round_tripped.organization.is_none()); + assert!(round_tripped.locality.is_none()); + assert!(round_tripped.state.is_none()); + assert!(round_tripped.country.is_none()); + } + + #[test] + fn only_populated_attributes_are_emitted_in_order() { + let dn = DistinguishedName { + common_name: "leaf.example.com".to_string(), + organization: Some("Example Corp".to_string()), + country: Some("US".to_string()), + ..Default::default() + }; + + let x509_name = dn.as_x509_name().unwrap(); + + // Two RDNs were skipped (OU, L, ST were unset) leaving CN, O, C in order. + assert_eq!( + x509_name.to_string(), + "CN=leaf.example.com,O=Example Corp,C=US" + ); + } + + #[test] + fn empty_string_attributes_are_skipped() { + let dn = DistinguishedName { + common_name: "leaf.example.com".to_string(), + organization_unit: Some(String::new()), + country: Some("US".to_string()), + ..Default::default() + }; + + // An explicitly empty `OU` is treated the same as `None` and dropped. + let x509_name = dn.as_x509_name().unwrap(); + assert_eq!(x509_name.to_string(), "CN=leaf.example.com,C=US"); + } + + #[test] + fn metacharacters_in_values_do_not_panic_or_inject() { + // A value containing RFC 4514 metacharacters used to either panic. + // It must now produce exactly one CN attribute carrying the literal value. + let dn = DistinguishedName { + common_name: "Acme, Inc.+O=Evil".to_string(), + ..Default::default() + }; + + let x509_name = dn.as_x509_name().unwrap(); + + // Exactly one RDN, one attribute (the CN), no injected O/other RDNs. + assert_eq!(x509_name.0.len(), 1, "expected a single RDN"); + let attrs: Vec<_> = x509_name.0.iter().flat_map(|rdn| rdn.0.iter()).collect(); + assert_eq!(attrs.len(), 1, "expected a single attribute"); + assert_eq!(attrs[0].oid, rfc4519::CN); + + // The literal value survives a round-trip unchanged. + let round_tripped = DistinguishedName::from_x509_name(&x509_name).unwrap(); + assert_eq!(round_tripped.common_name, "Acme, Inc.+O=Evil"); + assert!(round_tripped.organization.is_none()); + } +} diff --git a/src/error.rs b/src/error.rs index 723093a..0a144ab 100644 --- a/src/error.rs +++ b/src/error.rs @@ -204,3 +204,5 @@ impl From for CertKitError { CertKitError::RsaPkcs1Error(err.to_string()) } } + +pub type Result = std::result::Result; diff --git a/src/issuer.rs b/src/issuer.rs index d4d1b53..e44755d 100644 --- a/src/issuer.rs +++ b/src/issuer.rs @@ -1,3 +1,5 @@ +//! Certificate issuance and CA operations. + use std::vec; use der::Encode; @@ -13,8 +15,9 @@ use crate::cert::extensions::ExtendedKeyUsage; use crate::cert::extensions::ExtendedKeyUsageOption; use crate::cert::extensions::KeyUsage; use crate::cert::extensions::KeyUsages; +use crate::cert::extensions::SubjectKeyIdentifier; use crate::cert::params::Validity; -use crate::cert::params::{CertificationRequestInfo, DistinguishedName, ExtensionParam}; +use crate::cert::params::{CertificateParams, DistinguishedName, ExtensionParam}; use crate::key::KeyPair; use crate::tbs_certificate::TbsCertificate; @@ -46,7 +49,7 @@ use crate::tbs_certificate::TbsCertificate; /// ```rust /// use certkit::{ /// key::KeyPair, -/// cert::{Certificate, CertificateWithPrivateKey, params::{CertificationRequestInfo, DistinguishedName, Validity}}, +/// cert::{Certificate, CertificateWithPrivateKey, params::{CertificateParams, DistinguishedName, Validity}}, /// issuer::Issuer, /// }; /// @@ -57,17 +60,14 @@ use crate::tbs_certificate::TbsCertificate; /// .organization("Example Corp".to_string()) /// .build(); /// -/// let ca_cert_info = CertificationRequestInfo::builder() +/// let ca_cert_info = CertificateParams::builder() /// .subject(ca_subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&ca_key)) /// .is_ca(true) /// .build(); /// -/// let ca_cert = Certificate::new_self_signed(&ca_cert_info, &ca_key); -/// let ca_issuer = CertificateWithPrivateKey { -/// cert: ca_cert, -/// key: ca_key, -/// }; +/// let ca_cert = Certificate::new_self_signed(&ca_cert_info, &ca_key)?; +/// let ca_issuer = CertificateWithPrivateKey::new(ca_cert, ca_key); /// /// // Issue an end-entity certificate /// let end_entity_key = KeyPair::generate_rsa(2048)?; @@ -75,13 +75,13 @@ use crate::tbs_certificate::TbsCertificate; /// .common_name("client.example.com".to_string()) /// .build(); /// -/// let end_entity_info = CertificationRequestInfo::builder() +/// let end_entity_info = CertificateParams::builder() /// .subject(end_entity_subject) /// .subject_public_key(certkit::key::PublicKey::from_key_pair(&end_entity_key)) /// .build(); /// -/// let validity = Validity::for_days(90); -/// let issued_cert = ca_issuer.issue(&end_entity_info, validity); +/// let validity = Validity::for_days(90)?; +/// let issued_cert = ca_issuer.issue(&end_entity_info, validity)?; /// /// println!("Certificate issued successfully"); /// # Ok::<(), certkit::error::CertKitError>(()) @@ -93,7 +93,7 @@ use crate::tbs_certificate::TbsCertificate; /// use certkit::{ /// issuer::Issuer, /// key::KeyPair, -/// cert::params::{DistinguishedName, CertificationRequestInfo, Validity}, +/// cert::params::{DistinguishedName, CertificateParams, Validity}, /// }; /// /// struct CustomCA { @@ -103,19 +103,13 @@ use crate::tbs_certificate::TbsCertificate; /// } /// /// impl Issuer for CustomCA { -/// fn issuer_name(&self) -> DistinguishedName { -/// self.name.clone() +/// fn issuer_name(&self) -> Result { +/// Ok(self.name.clone()) /// } /// /// fn signing_key(&self) -> &KeyPair { /// &self.key /// } -/// -/// fn serial_number(&self) -> Vec { -/// let serial = self.next_serial.get(); -/// self.next_serial.set(serial + 1); -/// serial.to_be_bytes().to_vec() -/// } /// } /// ``` pub trait Issuer { @@ -124,9 +118,12 @@ pub trait Issuer { /// This name will appear in the "Issuer" field of issued certificates /// and should uniquely identify the certificate authority. /// - /// # Returns + /// Returns /// A `DistinguishedName` representing the issuer's identity. - fn issuer_name(&self) -> DistinguishedName; + /// + /// # Errors + /// A `CertKitError` if the name cannot be extracted. + fn issuer_name(&self) -> Result; /// Returns the signing key of the issuer. /// @@ -146,13 +143,24 @@ pub trait Issuer { /// This method should return a different value for each certificate issued. /// /// # Returns - /// A byte vector containing the serial number for the next certificate. + /// A byte vector containing a CSPRNG-generated serial number that + /// conforms to RFC 5280 §4.1.2.2 (positive, non-zero, at most 20 octets). /// - /// # Implementation Notes - /// - Serial numbers should be unique within the CA's scope - /// - Consider using incrementing counters or random values - /// - Avoid predictable patterns that could aid attacks - fn serial_number(&self) -> Vec; + /// # Default Implementation + /// Generates 20 random bytes via [`rand_core::OsRng`], clears the leading + /// bit (so the DER INTEGER is positive), and sets the trailing bit (so the + /// value is never zero). Override this if you need deterministic or + /// counter-based serial numbers. + fn serial_number(&self) -> Vec { + use rand_core::RngCore; + let mut buf = [0u8; 20]; + rand_core::OsRng.fill_bytes(&mut buf); + // Ensure leading bit is 0 so the DER INTEGER is positive. + buf[0] &= 0x7F; + // Ensure non-zero. + buf[19] |= 0x01; + buf.to_vec() + } /// Issues a certificate based on the provided certification request information. /// @@ -175,8 +183,9 @@ pub trait Issuer { /// /// # Extension Processing /// The method automatically adds several extensions: - /// - **Basic Constraints**: Set to CA=true for the issuer + /// - **Basic Constraints**: Set according to the request's `is_ca` flag /// - **Authority Key Identifier**: Links to the issuing CA + /// - **Subject Key Identifier**: SHA-1 hash of the subject's public key /// - **Key Usage**: Based on the certificate type (CA vs end-entity) /// - **Extended Key Usage**: Based on requested usage types /// @@ -185,63 +194,96 @@ pub trait Issuer { /// ```rust /// use certkit::{ /// key::KeyPair, - /// cert::{Certificate, CertificateWithPrivateKey, params::{CertificationRequestInfo, DistinguishedName, Validity}}, + /// cert::{Certificate, CertificateWithPrivateKey, params::{CertificateParams, DistinguishedName, Validity}}, /// issuer::Issuer, /// }; /// /// // Set up CA /// let ca_key = KeyPair::generate_rsa(2048)?; /// let ca_subject = DistinguishedName::builder().common_name("Test CA".to_string()).build(); - /// let ca_cert_info = CertificationRequestInfo::builder() + /// let ca_cert_info = CertificateParams::builder() /// .subject(ca_subject).subject_public_key(certkit::key::PublicKey::from_key_pair(&ca_key)).is_ca(true).build(); - /// let ca_cert = Certificate::new_self_signed(&ca_cert_info, &ca_key); - /// let ca_issuer = CertificateWithPrivateKey { cert: ca_cert, key: ca_key }; + /// let ca_cert = Certificate::new_self_signed(&ca_cert_info, &ca_key)?; + /// let ca_issuer = CertificateWithPrivateKey::new(ca_cert, ca_key); /// /// // Create certificate request /// let end_key = KeyPair::generate_ecdsa_p256(); /// let end_subject = DistinguishedName::builder().common_name("end-entity.com".to_string()).build(); - /// let cert_request = CertificationRequestInfo::builder() + /// let cert_request = CertificateParams::builder() /// .subject(end_subject).subject_public_key(certkit::key::PublicKey::from_key_pair(&end_key)).build(); /// /// // Issue the certificate - /// let validity = Validity::for_days(365); - /// let issued_cert = ca_issuer.issue(&cert_request, validity); + /// let validity = Validity::for_days(365)?; + /// let issued_cert = ca_issuer.issue(&cert_request, validity)?; /// println!("Certificate issued with {} extensions", - /// issued_cert.to_cert_info()?.extensions.len()); + /// issued_cert.params()?.extensions.len()); /// # Ok::<(), certkit::error::CertKitError>(()) /// ``` - fn issue(&self, cert_request: &CertificationRequestInfo, validity: Validity) -> Certificate { + fn issue( + &self, + cert_request: &CertificateParams, + validity: Validity, + ) -> Result { let signature_algo = match self.signing_key() { #[cfg(feature = "rsa")] KeyPair::Rsa { .. } => SignatureAlgorithm::Sha256WithRSA, #[cfg(feature = "p256")] KeyPair::EcdsaP256 { .. } => SignatureAlgorithm::Sha256WithECDSA, #[cfg(feature = "p384")] - KeyPair::EcdsaP384 { .. } => SignatureAlgorithm::Sha256WithECDSA, + KeyPair::EcdsaP384 { .. } => SignatureAlgorithm::Sha384WithECDSA, #[cfg(feature = "p521")] - KeyPair::EcdsaP521 { .. } => SignatureAlgorithm::Sha256WithECDSA, + KeyPair::EcdsaP521 { .. } => SignatureAlgorithm::Sha512WithECDSA, #[cfg(feature = "ed25519")] - KeyPair::Ed25519 { .. } => SignatureAlgorithm::Sha256WithEdDSA, + KeyPair::Ed25519 { .. } => SignatureAlgorithm::Ed25519, }; - let public_key_info = self.signing_key().as_spki(); - let key_id = ::digest(public_key_info.subject_public_key.raw_bytes()); - let issuer_dn = self.issuer_name(); + log::debug!( + "issuing certificate for \"{}\" (CA: {}, signature algorithm: {:?})", + cert_request.subject.common_name, + cert_request.is_ca, + signature_algo + ); + // Authority Key Identifier: SHA-1 of the issuer's public key, so issued + // certs point back to this CA. + // + // SHA-1 is used here per RFC 5280 §4.2.1.2 Method 1. + let issuer_spki = self.signing_key().as_spki(); let authority_key_id = AuthorityKeyIdentifier { - key_identifier: key_id.to_vec(), - authority_cert_issuer: issuer_dn.clone(), - authority_cert_serial_number: self.serial_number(), + key_identifier: ::digest( + issuer_spki.subject_public_key.raw_bytes(), + ) + .to_vec(), + // PKIX profile: identify the issuer by key id only. + authority_cert_issuer: None, + authority_cert_serial_number: None, }; + // Subject Key Identifier: SHA-1 of the subject's own key, computed the + // same way, so a child's AKI matches this cert's SKI during path building. + let subject_spki = cert_request.subject_public_key.as_spki(); + let subject_key_id = SubjectKeyIdentifier { + key_identifier: ::digest( + subject_spki.subject_public_key.raw_bytes(), + ) + .to_vec(), + }; + + let issuer_dn = self.issuer_name()?; + let basic_constraints = BasicConstraints { is_ca: cert_request.is_ca, - max_path_length: None, + max_path_length: if cert_request.is_ca { + cert_request.max_path_length + } else { + None + }, }; let mut extensions: Vec = vec![ - ExtensionParam::from_extension(basic_constraints, true), - ExtensionParam::from_extension(authority_key_id, false), + ExtensionParam::from_extension(basic_constraints, true)?, + ExtensionParam::from_extension(authority_key_id, false)?, + ExtensionParam::from_extension(subject_key_id, false)?, ]; let mut key_usage_flags: FlagSet = FlagSet::empty(); @@ -256,7 +298,17 @@ pub trait Issuer { ExtendedKeyUsageOption::ClientAuth | ExtendedKeyUsageOption::ServerAuth | ExtendedKeyUsageOption::EmailProtection => { - key_usage_flags |= KeyUsages::KeyEncipherment; + // TLS 1.3 with ECDSA/Ed25519 requires DigitalSignature. + // KeyEncipherment is only needed for RSA key transport + // (TLS ≤1.2), so set it conditionally. + key_usage_flags |= KeyUsages::DigitalSignature; + #[cfg(feature = "rsa")] + if matches!( + cert_request.subject_public_key, + crate::key::PublicKey::Rsa(_) + ) { + key_usage_flags |= KeyUsages::KeyEncipherment; + } } ExtendedKeyUsageOption::CodeSigning | ExtendedKeyUsageOption::TimeStamping @@ -268,47 +320,174 @@ pub trait Issuer { if !key_usage_flags.is_empty() { let key_usage = KeyUsage(key_usage_flags); - extensions.push(ExtensionParam::from_extension(key_usage, true)); + extensions.push(ExtensionParam::from_extension(key_usage, true)?); } if !cert_request.usages.is_empty() { let extended_key_usage = ExtendedKeyUsage { usage: cert_request.usages.clone(), }; - extensions.push(ExtensionParam::from_extension(extended_key_usage, true)); + extensions.push(ExtensionParam::from_extension(extended_key_usage, true)?); } - let combined_extensions = cert_request + let combined_extensions: Vec = cert_request .extensions .iter() .cloned() .chain(extensions) .collect(); + log::trace!("certificate has {} extension(s)", combined_extensions.len()); + + // RFC 5280 §4.1.2.2: the serial number is a positive integer of at most + // 20 octets. Validate any serial before use so a bad override is a clear + // error rather than a panic deeper down. + let serial_number = self.serial_number(); + if serial_number.is_empty() || serial_number.len() > 20 { + return Err(crate::error::CertKitError::InvalidInput(format!( + "serial number must be 1..=20 octets, got {}", + serial_number.len() + ))); + } let tbs_cert = TbsCertificate { - serial_number: vec![1], + serial_number, signature_algorithm: signature_algo.clone(), issuer: issuer_dn, - not_before: validity.not_before, - not_after: validity.not_after, + not_before: validity.not_before(), + not_after: validity.not_after(), subject: cert_request.subject.clone(), subject_public_key: cert_request.subject_public_key.clone(), extensions: combined_extensions, }; - let tbs_cert_inner = tbs_cert.to_tbs_certificate_inner(); + let tbs_cert_inner = tbs_cert.to_tbs_certificate_inner()?; let signature = self .signing_key() - .sign_data(&tbs_cert_inner.to_der().unwrap()) - .unwrap(); + .sign_data(&tbs_cert_inner.to_der()?) + .map_err(|e| { + crate::error::CertKitError::CertificateError(format!("signing failed: {e}")) + })?; + log::trace!("certificate signed ({} byte signature)", signature.len()); let cert_inner = CertificateInner { signature_algorithm: tbs_cert_inner.signature.clone(), tbs_certificate: tbs_cert_inner, - signature: der::asn1::BitString::from_bytes(&signature).unwrap(), + signature: der::asn1::BitString::from_bytes(&signature)?, }; - Certificate { inner: cert_inner } + Ok(Certificate::from_inner(cert_inner)) + } +} + +#[cfg(test)] +mod tests { + use super::Issuer; + use crate::cert::extensions::{ + AuthorityKeyIdentifier, SubjectKeyIdentifier, ToAndFromX509Extension, + }; + use crate::cert::params::{CertificateParams, DistinguishedName, Validity}; + use crate::cert::{Certificate, CertificateWithPrivateKey}; + use crate::key::{KeyPair, PublicKey}; + + fn request(common_name: &str, key: &KeyPair, is_ca: bool) -> CertificateParams { + crate::init_test_logger(); + CertificateParams::builder() + .subject( + DistinguishedName::builder() + .common_name(common_name.to_string()) + .build(), + ) + .subject_public_key(PublicKey::from_key_pair(key)) + .is_ca(is_ca) + .build() + } + + /// Returns the first extension of type `E` carried by `cert`, if present. + fn extension(cert: &Certificate) -> Option { + let info = cert.params().unwrap(); + info.extensions + .iter() + .find(|ext| ext.oid == E::OID) + .map(|ext| ext.to_extension::().unwrap()) + } + + #[cfg(feature = "p256")] + #[test] + fn p256_signature_algorithm_is_ecdsa_with_sha256() { + let key = KeyPair::generate_ecdsa_p256(); + let cert = Certificate::new_self_signed(&request("p256.ca", &key, true), &key).unwrap(); + let expected = const_oid::db::rfc5912::ECDSA_WITH_SHA_256; + assert_eq!(cert.inner().signature_algorithm.oid, expected); + assert_eq!(cert.inner().tbs_certificate.signature.oid, expected); + } + + #[cfg(feature = "p384")] + #[test] + fn p384_signature_algorithm_is_ecdsa_with_sha384() { + let key = KeyPair::generate_ecdsa_p384(); + let cert = Certificate::new_self_signed(&request("p384.ca", &key, true), &key).unwrap(); + let expected = const_oid::db::rfc5912::ECDSA_WITH_SHA_384; + assert_eq!(cert.inner().signature_algorithm.oid, expected); + assert_eq!(cert.inner().tbs_certificate.signature.oid, expected); + } + + #[cfg(feature = "p521")] + #[test] + fn p521_signature_algorithm_is_ecdsa_with_sha512() { + let key = KeyPair::generate_ecdsa_p521(); + let cert = Certificate::new_self_signed(&request("p521.ca", &key, true), &key).unwrap(); + let expected = const_oid::db::rfc5912::ECDSA_WITH_SHA_512; + assert_eq!(cert.inner().signature_algorithm.oid, expected); + assert_eq!(cert.inner().tbs_certificate.signature.oid, expected); + } + + /// Issued certificates carry a Subject Key Identifier, and their Authority + /// Key Identifier holds only the key id and matches the issuer's SKI, so a + /// chain links up during path building. + #[cfg(feature = "p256")] + #[test] + fn issued_chain_links_aki_to_issuer_ski() { + let root_key = KeyPair::generate_ecdsa_p256(); + let root = + Certificate::new_self_signed(&request("Root CA", &root_key, true), &root_key).unwrap(); + let root_ca = CertificateWithPrivateKey::new(root, root_key); + + let int_key = KeyPair::generate_ecdsa_p256(); + let int = root_ca + .issue( + &request("Intermediate CA", &int_key, true), + Validity::for_days(365).unwrap(), + ) + .unwrap(); + let int_ca = CertificateWithPrivateKey::new(int, int_key); + + let leaf_key = KeyPair::generate_ecdsa_p256(); + let leaf = int_ca + .issue( + &request("leaf", &leaf_key, false), + Validity::for_days(365).unwrap(), + ) + .unwrap(); + + let root_ski = extension::(root_ca.cert()).expect("root SKI"); + let int_ski = extension::(int_ca.cert()).expect("intermediate SKI"); + assert!( + extension::(&leaf).is_some(), + "leaf must carry a Subject Key Identifier" + ); + + let int_aki = extension::(int_ca.cert()).expect("intermediate AKI"); + let leaf_aki = extension::(&leaf).expect("leaf AKI"); + + // keyId-only AKI (PKIX profile). + assert!(int_aki.authority_cert_issuer.is_none()); + assert!(int_aki.authority_cert_serial_number.is_none()); + assert!(leaf_aki.authority_cert_issuer.is_none()); + assert!(leaf_aki.authority_cert_serial_number.is_none()); + + // Each cert's AKI key id equals its issuer's SKI key id. + assert_eq!(int_aki.key_identifier, root_ski.key_identifier); + assert_eq!(leaf_aki.key_identifier, int_ski.key_identifier); } } diff --git a/src/key.rs b/src/key.rs index fb19dea..18df938 100644 --- a/src/key.rs +++ b/src/key.rs @@ -1,9 +1,39 @@ -use crate::error::CertKitError; +//! Key generation, import/export, and cryptographic signing. + +use std::fmt; + use der::pem::LineEnding; -pub type Result = std::result::Result; +use pkcs8::{EncodePrivateKey, PrivateKeyInfo}; + +use crate::error::{CertKitError, Result}; + +/// Identifies the cryptographic algorithm of a key pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KeyType { + /// RSA key. + Rsa, + /// ECDSA key on the NIST P-256 curve. + EcdsaP256, + /// ECDSA key on the NIST P-384 curve. + EcdsaP384, + /// ECDSA key on the NIST P-521 curve. + EcdsaP521, + /// Ed25519 key. + Ed25519, +} + +impl fmt::Display for KeyType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Rsa => write!(f, "RSA"), + Self::EcdsaP256 => write!(f, "ECDSA P-256"), + Self::EcdsaP384 => write!(f, "ECDSA P-384"), + Self::EcdsaP521 => write!(f, "ECDSA P-521"), + Self::Ed25519 => write!(f, "Ed25519"), + } + } +} -#[cfg(feature = "p521")] -use ecdsa::VerifyingKey; #[cfg(feature = "ed25519")] use ed25519_dalek::SigningKey as Ed25519SigningKey; #[cfg(feature = "ed25519")] @@ -13,8 +43,6 @@ use p256::ecdsa::{SigningKey as P256SigningKey, VerifyingKey as P256VerifyingKey #[cfg(feature = "p384")] use p384::ecdsa::{SigningKey as P384SigningKey, VerifyingKey as P384VerifyingKey}; #[cfg(feature = "p521")] -use p521::NistP521; -#[cfg(feature = "p521")] use p521::ecdsa::SigningKey as P521SigningKey; #[cfg(feature = "rsa")] use rsa::pkcs1v15::SigningKey as RsaSigningKey; @@ -26,6 +54,7 @@ use rsa::signature::Signer as RsaSigner; use rsa::{ RsaPrivateKey, RsaPublicKey, pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPublicKey}, + traits::PublicKeyParts, }; #[cfg(feature = "rsa")] use sha2::Sha256; //only used with RSA keys. @@ -71,7 +100,7 @@ use sha2::Sha256; //only used with RSA keys. /// - ECDSA keys provide equivalent security with smaller key sizes /// - Ed25519 provides high security and performance /// - Choose the appropriate algorithm based on your security requirements and compatibility needs -#[derive(Debug, Clone)] +#[derive(Clone)] pub enum KeyPair { /// RSA key pair. /// @@ -106,12 +135,12 @@ pub enum KeyPair { /// ECDSA P-521 key pair. /// /// # Fields - /// * `signing_key` - The signing key. - /// * `verifying_key` - The verifying key. + /// * `secret_key` - The secret key. + /// * `public_key` - The public key. #[cfg(feature = "p521")] EcdsaP521 { - signing_key: ecdsa::SigningKey, - verifying_key: ecdsa::VerifyingKey, + secret_key: p521::SecretKey, + public_key: p521::PublicKey, }, /// Ed25519 key pair. /// @@ -121,7 +150,40 @@ pub enum KeyPair { Ed25519 { signing_key: Ed25519SigningKey }, } -use pkcs8::{EncodePrivateKey, PrivateKeyInfo}; +impl std::fmt::Debug for KeyPair { + /// Formats the key pair for debugging **without** exposing private key material. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + #[cfg(feature = "rsa")] + Self::Rsa { public, .. } => f + .debug_struct("Rsa") + .field("bits", &public.n().bits()) + .finish_non_exhaustive(), + #[cfg(feature = "p256")] + Self::EcdsaP256 { .. } => f.debug_struct("EcdsaP256").finish_non_exhaustive(), + #[cfg(feature = "p384")] + Self::EcdsaP384 { .. } => f.debug_struct("EcdsaP384").finish_non_exhaustive(), + #[cfg(feature = "p521")] + Self::EcdsaP521 { .. } => f.debug_struct("EcdsaP521").finish_non_exhaustive(), + #[cfg(feature = "ed25519")] + Self::Ed25519 { .. } => f.debug_struct("Ed25519").finish_non_exhaustive(), + } + } +} + +/// Builds the P-521 ECDSA signing key for a stored secret key. +/// +/// P-521 ECDSA signs over a SHA-512 prehash, which the `p521` crate implements +/// only on its `ecdsa::SigningKey` newtype. We store the `elliptic_curve` +/// `SecretKey`/`PublicKey`. They implement the PKCS#8/SPKI/SEC1 encoders that +/// the newtypes lack and, unlike the newtypes, derive `Debug`. We recover the +/// signing key on demand here. The scalar is preserved exactly, so signatures +/// match the previous `ecdsa::SigningKey` path. +#[cfg(feature = "p521")] +fn p521_signing_key(secret_key: &p521::SecretKey) -> P521SigningKey { + P521SigningKey::from_bytes(&secret_key.to_bytes()) + .expect("a stored secret key always holds a valid scalar") +} impl KeyPair { /// Generate an RSA key pair with the specified number of bits. @@ -166,6 +228,12 @@ impl KeyPair { /// - 4096-bit keys offer maximum security but with performance trade-offs #[cfg(feature = "rsa")] pub fn generate_rsa(bits: usize) -> Result { + if bits < 2048 { + return Err(CertKitError::InvalidInput(format!( + "RSA key size {bits} bits is below the minimum of 2048" + ))); + } + log::debug!("generating RSA-{bits} key pair"); let mut rng = rand_core::OsRng; let private = RsaPrivateKey::new(&mut rng, bits)?; let public = RsaPublicKey::from(&private); @@ -207,6 +275,7 @@ impl KeyPair { /// - Fast signature generation and verification #[cfg(feature = "p256")] pub fn generate_ecdsa_p256() -> Self { + log::debug!("generating ECDSA P-256 key pair"); let mut rng = rand_core::OsRng; let signing_key = P256SigningKey::random(&mut rng); let verifying_key = signing_key.verifying_key().to_owned(); @@ -245,6 +314,7 @@ impl KeyPair { /// - Slightly larger signatures than P-256 #[cfg(feature = "p384")] pub fn generate_ecdsa_p384() -> Self { + log::debug!("generating ECDSA P-384 key pair"); let mut rng = rand_core::OsRng; let signing_key = P384SigningKey::random(&mut rng); let verifying_key = signing_key.verifying_key().to_owned(); @@ -283,13 +353,13 @@ impl KeyPair { /// - Larger key and signature sizes than P-256/P-384 #[cfg(feature = "p521")] pub fn generate_ecdsa_p521() -> Self { + log::debug!("generating ECDSA P-521 key pair"); let mut rng = rand_core::OsRng; - let signing_key: ecdsa::SigningKey = - ecdsa::SigningKey::::random(&mut rng); - let verifying_key = signing_key.verifying_key().to_owned(); + let secret_key = p521::SecretKey::random(&mut rng); + let public_key = secret_key.public_key(); KeyPair::EcdsaP521 { - signing_key, - verifying_key, + secret_key, + public_key, } } @@ -326,6 +396,7 @@ impl KeyPair { /// - Deterministic signatures (no random nonce required) #[cfg(feature = "ed25519")] pub fn generate_ed25519() -> Self { + log::debug!("generating Ed25519 key pair"); let mut rng = rand_core::OsRng; let signing_key: Ed25519SigningKey = Ed25519SigningKey::generate(&mut rng); KeyPair::Ed25519 { signing_key } @@ -366,13 +437,17 @@ impl KeyPair { pub fn get_public_key_der(&self) -> Vec { match self { #[cfg(feature = "rsa")] - KeyPair::Rsa { public, .. } => public.to_pkcs1_der().unwrap().as_bytes().to_vec(), + KeyPair::Rsa { public, .. } => public + .to_pkcs1_der() + .expect("valid RSA public key always encodes to PKCS#1 DER") + .as_bytes() + .to_vec(), #[cfg(feature = "p256")] KeyPair::EcdsaP256 { verifying_key, .. } => verifying_key.to_sec1_bytes().to_vec(), #[cfg(feature = "p384")] KeyPair::EcdsaP384 { verifying_key, .. } => verifying_key.to_sec1_bytes().to_vec(), #[cfg(feature = "p521")] - KeyPair::EcdsaP521 { verifying_key, .. } => verifying_key.to_sec1_bytes().to_vec(), + KeyPair::EcdsaP521 { public_key, .. } => public_key.to_sec1_bytes().to_vec(), #[cfg(feature = "ed25519")] KeyPair::Ed25519 { signing_key } => signing_key.verifying_key().to_bytes().to_vec(), } @@ -404,8 +479,8 @@ impl KeyPair { EncodePrivateKey::to_pkcs8_pem(signing_key, LineEnding::default()) } #[cfg(feature = "p521")] - KeyPair::EcdsaP521 { signing_key, .. } => { - EncodePrivateKey::to_pkcs8_pem(signing_key, LineEnding::default()) + KeyPair::EcdsaP521 { secret_key, .. } => { + EncodePrivateKey::to_pkcs8_pem(secret_key, LineEnding::default()) } #[cfg(feature = "ed25519")] KeyPair::Ed25519 { signing_key, .. } => { @@ -418,6 +493,73 @@ impl KeyPair { Ok(key.as_str().to_string()) } + /// Encodes the private key in PKCS#8 DER format. + /// + /// # Returns + /// A `Result` containing the DER-encoded private key bytes, or a `CertKitError` on failure. + /// + /// # Errors + /// Returns `CertKitError::EncodingError` if the key cannot be encoded. + /// + /// # Examples + /// + /// ```rust + /// use certkit::key::KeyPair; + /// + /// let key = KeyPair::generate_ecdsa_p256(); + /// let der_bytes = key.encode_private_key_der().unwrap(); + /// println!("Private key DER: {} bytes", der_bytes.len()); + /// + /// // Round-trip: the DER can be re-imported + /// let restored = KeyPair::import_from_der(&der_bytes).unwrap(); + /// ``` + pub fn encode_private_key_der(&self) -> Result> { + let doc = (match &self { + #[cfg(feature = "rsa")] + KeyPair::Rsa { private, .. } => private.to_pkcs8_der(), + #[cfg(feature = "p256")] + KeyPair::EcdsaP256 { signing_key, .. } => EncodePrivateKey::to_pkcs8_der(signing_key), + #[cfg(feature = "p384")] + KeyPair::EcdsaP384 { signing_key, .. } => EncodePrivateKey::to_pkcs8_der(signing_key), + #[cfg(feature = "p521")] + KeyPair::EcdsaP521 { secret_key, .. } => EncodePrivateKey::to_pkcs8_der(secret_key), + #[cfg(feature = "ed25519")] + KeyPair::Ed25519 { signing_key, .. } => EncodePrivateKey::to_pkcs8_der(signing_key), + }) + .map_err(|e| e.to_string()) + .map_err(CertKitError::EncodingError)?; + + Ok(doc.as_bytes().to_vec()) + } + + /// Returns the [`KeyType`] of this key pair. + /// + /// This is a lightweight accessor that lets callers discover the + /// algorithm without matching on the full `KeyPair` enum. + /// + /// # Examples + /// + /// ```rust + /// use certkit::key::{KeyPair, KeyType}; + /// + /// let key = KeyPair::generate_ecdsa_p384(); + /// assert_eq!(key.key_type(), KeyType::EcdsaP384); + /// ``` + pub fn key_type(&self) -> KeyType { + match self { + #[cfg(feature = "rsa")] + KeyPair::Rsa { .. } => KeyType::Rsa, + #[cfg(feature = "p256")] + KeyPair::EcdsaP256 { .. } => KeyType::EcdsaP256, + #[cfg(feature = "p384")] + KeyPair::EcdsaP384 { .. } => KeyType::EcdsaP384, + #[cfg(feature = "p521")] + KeyPair::EcdsaP521 { .. } => KeyType::EcdsaP521, + #[cfg(feature = "ed25519")] + KeyPair::Ed25519 { .. } => KeyType::Ed25519, + } + } + /// Imports a key pair from DER-encoded data. /// /// Attempts to decode DER-encoded private key data and create a `KeyPair`. @@ -458,12 +600,11 @@ impl KeyPair { /// } /// ``` pub fn import_from_der(der: &[u8]) -> Result { + log::trace!("importing key from {} bytes of DER", der.len()); // Try RSA PKCS#1 first #[cfg(feature = "rsa")] - if let (Ok(private), Ok(public)) = ( - RsaPrivateKey::from_pkcs1_der(der), - RsaPublicKey::from_pkcs1_der(der), - ) { + if let Ok(private) = RsaPrivateKey::from_pkcs1_der(der) { + let public = RsaPublicKey::from(&private); return Ok(KeyPair::Rsa { private: Box::new(private), public, @@ -504,11 +645,11 @@ impl KeyPair { } // Try ECDSA P-521 PKCS#8 #[cfg(feature = "p521")] - if let Ok(signing_key) = ecdsa::SigningKey::::try_from(private_key_info.clone()) { - let verifying_key = signing_key.verifying_key().to_owned(); + if let Ok(secret_key) = p521::SecretKey::try_from(private_key_info.clone()) { + let public_key = secret_key.public_key(); return Ok(KeyPair::EcdsaP521 { - signing_key, - verifying_key, + secret_key, + public_key, }); } @@ -566,6 +707,7 @@ impl KeyPair { /// - Contain valid base64-encoded DER data /// - Represent a supported key type (RSA, ECDSA P-256/P-384/P-521, Ed25519) pub fn import_from_pkcs8_pem(pem_str: &str) -> Result { + log::trace!("importing key from PKCS#8 PEM"); let pemd = pem::parse(pem_str) .map_err(|_| CertKitError::DecodingError("Failed to parse PEM".to_string()))?; @@ -613,30 +755,28 @@ impl KeyPair { match self { #[cfg(feature = "rsa")] KeyPair::Rsa { public, .. } => { - x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(public.clone()).unwrap() + x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(public.clone()) + .expect("valid RSA key always encodes to SPKI") } #[cfg(feature = "p256")] KeyPair::EcdsaP256 { verifying_key, .. } => { - x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key).unwrap() + x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key) + .expect("valid P-256 key always encodes to SPKI") } #[cfg(feature = "p384")] KeyPair::EcdsaP384 { verifying_key, .. } => { - x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key).unwrap() + x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key) + .expect("valid P-384 key always encodes to SPKI") } #[cfg(feature = "p521")] - KeyPair::EcdsaP521 { verifying_key, .. } => { - x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key).unwrap() + KeyPair::EcdsaP521 { public_key, .. } => { + x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*public_key) + .expect("valid P-521 key always encodes to SPKI") } #[cfg(feature = "ed25519")] KeyPair::Ed25519 { signing_key } => { - let pk_bytes = signing_key.verifying_key().to_bytes(); - x509_cert::spki::SubjectPublicKeyInfoOwned { - algorithm: x509_cert::spki::AlgorithmIdentifierOwned { - oid: const_oid::ObjectIdentifier::new_unwrap("1.3.101.112"), - parameters: None, - }, - subject_public_key: der::asn1::BitString::from_bytes(&pk_bytes).unwrap(), - } + x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(signing_key.verifying_key()) + .expect("valid Ed25519 key always encodes to SPKI") } } } @@ -694,6 +834,7 @@ impl KeyPair { /// - Ed25519 signatures are deterministic and consistent /// - All algorithms provide strong security when used properly pub fn sign_data(&self, data: &[u8]) -> Result> { + log::trace!("signing {} bytes of data", data.len()); match self { #[cfg(feature = "rsa")] KeyPair::Rsa { private, .. } => { @@ -706,20 +847,20 @@ impl KeyPair { KeyPair::EcdsaP256 { signing_key, .. } => { let signature: p256::ecdsa::Signature = p256::ecdsa::signature::Signer::sign(signing_key, data); - Ok(signature.to_vec()) + Ok(signature.to_der().as_bytes().to_vec()) } #[cfg(feature = "p384")] KeyPair::EcdsaP384 { signing_key, .. } => { let signature: p384::ecdsa::Signature = p384::ecdsa::signature::Signer::sign(signing_key, data); - Ok(signature.to_vec()) + Ok(signature.to_der().as_bytes().to_vec()) } #[cfg(feature = "p521")] - KeyPair::EcdsaP521 { signing_key, .. } => { - let skey: P521SigningKey = signing_key.clone().into(); + KeyPair::EcdsaP521 { secret_key, .. } => { + let signing_key = p521_signing_key(secret_key); let signature: p521::ecdsa::Signature = - p521::ecdsa::signature::Signer::sign(&skey, data); - Ok(signature.to_vec()) + p521::ecdsa::signature::Signer::sign(&signing_key, data); + Ok(signature.to_der().as_bytes().to_vec()) } #[cfg(feature = "ed25519")] KeyPair::Ed25519 { signing_key } => { @@ -731,6 +872,25 @@ impl KeyPair { } } +impl fmt::Display for KeyPair { + /// Formats the key pair as a human-readable string describing the algorithm + /// while never exposing the private key material. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + #[cfg(feature = "rsa")] + Self::Rsa { public, .. } => write!(f, "RSA-{}", public.n().bits()), + #[cfg(feature = "p256")] + Self::EcdsaP256 { .. } => write!(f, "ECDSA P-256"), + #[cfg(feature = "p384")] + Self::EcdsaP384 { .. } => write!(f, "ECDSA P-384"), + #[cfg(feature = "p521")] + Self::EcdsaP521 { .. } => write!(f, "ECDSA P-521"), + #[cfg(feature = "ed25519")] + Self::Ed25519 { .. } => write!(f, "Ed25519"), + } + } +} + /// Represents a public key for cryptographic operations. /// /// This enum encapsulates public keys from various cryptographic algorithms @@ -758,8 +918,6 @@ impl KeyPair { /// let der_bytes = public_key.to_der()?; /// println!("Public key DER size: {} bytes", der_bytes.len()); /// -/// // Note: from_der currently only supports RSA keys -/// // let restored_key = PublicKey::from_der(&der_bytes)?; /// # Ok(()) /// # } /// ``` @@ -776,7 +934,7 @@ pub enum PublicKey { EcdsaP384(P384VerifyingKey), /// ECDSA P-521 public key. #[cfg(feature = "p521")] - EcdsaP521(VerifyingKey), + EcdsaP521(p521::PublicKey), /// Ed25519 public key. #[cfg(feature = "ed25519")] Ed25519(Ed25519VerifyingKey), @@ -820,29 +978,63 @@ impl PublicKey { #[cfg(feature = "p384")] PublicKey::EcdsaP384(verifying_key) => Ok(verifying_key.to_sec1_bytes().to_vec()), #[cfg(feature = "p521")] - PublicKey::EcdsaP521(verifying_key) => Ok(verifying_key.to_sec1_bytes().to_vec()), + PublicKey::EcdsaP521(public_key) => Ok(public_key.to_sec1_bytes().to_vec()), #[cfg(feature = "ed25519")] PublicKey::Ed25519(verifying_key) => Ok(verifying_key.to_bytes().to_vec()), } } - /// Creates a public key from DER-encoded data. + /// Converts the public key to an X.509 `SubjectPublicKeyInfo`. + /// + /// Mirrors [`KeyPair::as_spki`] for the public half. Deriving a key + /// identifier from this SPKI yields the same value a CA derives from its + /// signing key, so a subject key identifier here matches the authority key + /// identifier of certificates this key later signs. + pub fn as_spki(&self) -> x509_cert::spki::SubjectPublicKeyInfoOwned { + match self { + #[cfg(feature = "rsa")] + PublicKey::Rsa(public) => { + x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(public.clone()) + .expect("valid RSA key always encodes to SPKI") + } + #[cfg(feature = "p256")] + PublicKey::EcdsaP256(verifying_key) => { + x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key) + .expect("valid P-256 key always encodes to SPKI") + } + #[cfg(feature = "p384")] + PublicKey::EcdsaP384(verifying_key) => { + x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key) + .expect("valid P-384 key always encodes to SPKI") + } + #[cfg(feature = "p521")] + PublicKey::EcdsaP521(public_key) => { + x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*public_key) + .expect("valid P-521 key always encodes to SPKI") + } + #[cfg(feature = "ed25519")] + PublicKey::Ed25519(verifying_key) => { + x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key) + .expect("valid Ed25519 key always encodes to SPKI") + } + } + } + + /// Decodes an RSA public key from PKCS#1 DER-encoded bytes. /// - /// Attempts to decode DER-encoded public key data. Currently supports - /// RSA public keys in PKCS#1 format. Support for other key types may - /// be added in future versions. + /// This method only supports RSA public keys in PKCS#1 (`RSAPublicKey`) + /// format. For other key types, use [`PublicKey::from_key_pair`] or + /// [`PublicKey::from_x509spki`]. /// /// # Arguments - /// * `der` - A byte slice containing the DER-encoded public key data + /// * `der` - A byte slice containing the PKCS#1 DER-encoded RSA public key /// /// # Returns /// A `Result` containing the `PublicKey` on success, or a `CertKitError` on failure. /// /// # Errors - /// Returns `CertKitError::DecodingError` if: - /// - The DER data is malformed - /// - The key type is not supported - /// - The key format is not recognized + /// Returns `CertKitError::RsaPkcs1Error` if the DER data is not a valid + /// PKCS#1 RSA public key. /// /// # Examples /// @@ -855,17 +1047,13 @@ impl PublicKey { /// let public_key = PublicKey::from_key_pair(&key_pair); /// let der_bytes = public_key.to_der()?; /// - /// // Recreate public key from DER - /// let restored_key = PublicKey::from_der(&der_bytes)?; + /// // Recreate public key from PKCS#1 DER + /// let restored_key = PublicKey::from_rsa_pkcs1_der(&der_bytes)?; /// # Ok(()) /// # } /// ``` - /// - /// # Limitations - /// Currently only supports RSA public keys. ECDSA and Ed25519 support - /// will be added in future versions. #[cfg(feature = "rsa")] - pub fn from_der(der: &[u8]) -> Result { + pub fn from_rsa_pkcs1_der(der: &[u8]) -> Result { let public = RsaPublicKey::from_pkcs1_der(der)?; Ok(PublicKey::Rsa(public)) } @@ -916,7 +1104,7 @@ impl PublicKey { #[cfg(feature = "p384")] KeyPair::EcdsaP384 { verifying_key, .. } => PublicKey::EcdsaP384(*verifying_key), #[cfg(feature = "p521")] - KeyPair::EcdsaP521 { verifying_key, .. } => PublicKey::EcdsaP521(*verifying_key), + KeyPair::EcdsaP521 { public_key, .. } => PublicKey::EcdsaP521(*public_key), #[cfg(feature = "ed25519")] KeyPair::Ed25519 { signing_key, .. } => PublicKey::Ed25519(signing_key.verifying_key()), } @@ -1015,15 +1203,13 @@ impl PublicKey { } #[cfg(feature = "p521")] const_oid::db::rfc5912::SECP_521_R_1 => { - let verifying_key = ecdsa::VerifyingKey::::from_sec1_bytes( - raw_bytes, - ) - .map_err(|_| { - CertKitError::DecodingError( - "Invalid P-521 public key bytes".to_string(), - ) - })?; - Ok(PublicKey::EcdsaP521(verifying_key)) + let public_key = + p521::PublicKey::from_sec1_bytes(raw_bytes).map_err(|_| { + CertKitError::DecodingError( + "Invalid P-521 public key bytes".to_string(), + ) + })?; + Ok(PublicKey::EcdsaP521(public_key)) } _ => Err(CertKitError::DecodingError(format!( "Unsupported EC curve OID: {params_oid}" @@ -1049,6 +1235,32 @@ impl PublicKey { ))), } } + + /// Returns the [`KeyType`] of this public key. + /// + /// # Examples + /// + /// ```rust + /// use certkit::key::{KeyPair, PublicKey, KeyType}; + /// + /// let kp = KeyPair::generate_ed25519(); + /// let pk = PublicKey::from_key_pair(&kp); + /// assert_eq!(pk.key_type(), KeyType::Ed25519); + /// ``` + pub fn key_type(&self) -> KeyType { + match self { + #[cfg(feature = "rsa")] + PublicKey::Rsa(_) => KeyType::Rsa, + #[cfg(feature = "p256")] + PublicKey::EcdsaP256(_) => KeyType::EcdsaP256, + #[cfg(feature = "p384")] + PublicKey::EcdsaP384(_) => KeyType::EcdsaP384, + #[cfg(feature = "p521")] + PublicKey::EcdsaP521(_) => KeyType::EcdsaP521, + #[cfg(feature = "ed25519")] + PublicKey::Ed25519(_) => KeyType::Ed25519, + } + } } #[cfg(test)] @@ -1058,6 +1270,7 @@ mod test { #[test] #[cfg(feature = "rsa")] fn pem_encode_decode_rsa() { + crate::init_test_logger(); let rsa = KeyPair::generate_rsa(2048).unwrap(); let rsa_der = rsa::pkcs8::EncodePrivateKey::to_pkcs8_der(match &rsa { KeyPair::Rsa { private, .. } => &**private, @@ -1072,6 +1285,7 @@ mod test { #[test] #[cfg(feature = "p256")] fn pem_encode_decode_ecdsa_p256() { + crate::init_test_logger(); let p256 = KeyPair::generate_ecdsa_p256(); let p256_der = p256::pkcs8::EncodePrivateKey::to_pkcs8_der(match &p256 { KeyPair::EcdsaP256 { signing_key, .. } => signing_key, @@ -1086,6 +1300,7 @@ mod test { #[test] #[cfg(feature = "p384")] fn pem_encode_decode_ecdsa_p384() { + crate::init_test_logger(); let p384 = KeyPair::generate_ecdsa_p384(); let p384_der = p384::pkcs8::EncodePrivateKey::to_pkcs8_der(match &p384 { KeyPair::EcdsaP384 { signing_key, .. } => signing_key, @@ -1100,9 +1315,10 @@ mod test { #[test] #[cfg(feature = "p521")] fn pem_encode_decode_ecdsa_p521() { + crate::init_test_logger(); let p521 = KeyPair::generate_ecdsa_p521(); let p521_der = p521::pkcs8::EncodePrivateKey::to_pkcs8_der(match &p521 { - KeyPair::EcdsaP521 { signing_key, .. } => signing_key, + KeyPair::EcdsaP521 { secret_key, .. } => secret_key, _ => unreachable!(), }) .unwrap(); @@ -1113,8 +1329,8 @@ mod test { #[test] #[cfg(feature = "ed25519")] - #[allow(unreachable_patterns)] //Depending on feature combination we may only support ED25519 fn pem_encode_decode_ed25519() { + crate::init_test_logger(); let ed = KeyPair::generate_ed25519(); let ed_der = ed25519_dalek::pkcs8::EncodePrivateKey::to_pkcs8_der(match &ed { KeyPair::Ed25519 { signing_key } => signing_key, @@ -1125,4 +1341,50 @@ mod test { let ed_decoded = KeyPair::import_from_pkcs8_pem(&ed_pem); assert!(ed_decoded.is_ok(), "Ed25519 PEM decode should succeed"); } + + // X.509 requires ECDSA signatures to be the DER-encoded ECDSA-Sig-Value + // SEQUENCE { r, s }. `sign_data` previously emitted the fixed-size r||s + // form, which is rejected by `Signature::from_der` and fails verification. + + #[test] + #[cfg(feature = "p256")] + fn ecdsa_p256_sign_data_is_der_and_verifies() { + use p256::ecdsa::signature::Verifier; + crate::init_test_logger(); + let key = KeyPair::generate_ecdsa_p256(); + let msg = b"certkit ecdsa signature payload"; + let sig_bytes = key.sign_data(msg).unwrap(); + let sig = p256::ecdsa::Signature::from_der(&sig_bytes) + .expect("ECDSA P-256 signature must be DER-encoded"); + let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(&key.get_public_key_der()).unwrap(); + vk.verify(msg, &sig).expect("signature must verify"); + } + + #[test] + #[cfg(feature = "p384")] + fn ecdsa_p384_sign_data_is_der_and_verifies() { + use p384::ecdsa::signature::Verifier; + crate::init_test_logger(); + let key = KeyPair::generate_ecdsa_p384(); + let msg = b"certkit ecdsa signature payload"; + let sig_bytes = key.sign_data(msg).unwrap(); + let sig = p384::ecdsa::Signature::from_der(&sig_bytes) + .expect("ECDSA P-384 signature must be DER-encoded"); + let vk = p384::ecdsa::VerifyingKey::from_sec1_bytes(&key.get_public_key_der()).unwrap(); + vk.verify(msg, &sig).expect("signature must verify"); + } + + #[test] + #[cfg(feature = "p521")] + fn ecdsa_p521_sign_data_is_der_and_verifies() { + use p521::ecdsa::signature::Verifier; + crate::init_test_logger(); + let key = KeyPair::generate_ecdsa_p521(); + let msg = b"certkit ecdsa signature payload"; + let sig_bytes = key.sign_data(msg).unwrap(); + let sig = p521::ecdsa::Signature::from_der(&sig_bytes) + .expect("ECDSA P-521 signature must be DER-encoded"); + let vk = p521::ecdsa::VerifyingKey::from_sec1_bytes(&key.get_public_key_der()).unwrap(); + vk.verify(msg, &sig).expect("signature must verify"); + } } diff --git a/src/lib.rs b/src/lib.rs index 5199f0c..86d4359 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,12 +20,21 @@ //! ## Key Features //! //! - **Pure Rust**: Built entirely with rustcrypto libraries -//! - **Certificate Chain Management**: Create and validate certificate hierarchies +//! - **Certificate Chain Building**: Create multi-level certificate hierarchies //! - **Self-Signed Certificates**: Generate root CA certificates //! - **Intermediate CAs**: Support for multi-level certificate authorities //! - **X.509 Extensions**: Comprehensive support for standard extensions //! - **Format Flexibility**: Import/export in both PEM and DER formats //! +//! ## Scope +//! +//! CertKit *builds* and *parses* certificates and keys. It does **not** verify +//! signatures or perform certificate-path/chain validation, pair it with a +//! verifier such as [`rustls`]/[`webpki`] when you need to validate a chain. +//! +//! [`rustls`]: https://docs.rs/rustls +//! [`webpki`]: https://docs.rs/webpki +//! //! ## Quick Start //! //! ### Generating a Self-Signed Certificate @@ -33,7 +42,7 @@ //! ```rust,no_run //! use certkit::{ //! key::KeyPair, -//! cert::{Certificate, params::{CertificationRequestInfo, DistinguishedName}}, +//! cert::{Certificate, params::{CertificateParams, DistinguishedName}}, //! }; //! //! # fn main() -> Result<(), certkit::error::CertKitError> { @@ -47,13 +56,13 @@ //! .country("US".to_string()) //! .build(); //! -//! let cert_info = CertificationRequestInfo::builder() +//! let cert_info = CertificateParams::builder() //! .subject(subject) //! .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) //! .build(); //! //! // Generate the self-signed certificate -//! let certificate = Certificate::new_self_signed(&cert_info, &key_pair); +//! let certificate = Certificate::new_self_signed(&cert_info, &key_pair)?; //! //! // Export to PEM format //! let pem_cert = certificate.to_pem()?; @@ -67,7 +76,7 @@ //! ```rust,no_run //! use certkit::{ //! key::KeyPair, -//! cert::{Certificate, CertificateWithPrivateKey, params::{CertificationRequestInfo, DistinguishedName, Validity}}, +//! cert::{Certificate, CertificateWithPrivateKey, params::{CertificateParams, DistinguishedName, Validity}}, //! issuer::Issuer, //! }; //! @@ -82,30 +91,27 @@ //! .organization("Example Corp".to_string()) //! .build(); //! -//! let ca_cert_info = CertificationRequestInfo::builder() +//! let ca_cert_info = CertificateParams::builder() //! .subject(ca_subject) //! .subject_public_key(certkit::key::PublicKey::from_key_pair(&ca_key)) //! .is_ca(true) //! .build(); //! -//! let ca_cert = Certificate::new_self_signed(&ca_cert_info, &ca_key); -//! let ca_with_key = CertificateWithPrivateKey { -//! cert: ca_cert, -//! key: ca_key, -//! }; +//! let ca_cert = Certificate::new_self_signed(&ca_cert_info, &ca_key)?; +//! let ca_with_key = CertificateWithPrivateKey::new(ca_cert, ca_key); //! //! // Create server certificate signed by CA //! let server_subject = DistinguishedName::builder() //! .common_name("server.example.com".to_string()) //! .build(); //! -//! let server_cert_info = CertificationRequestInfo::builder() +//! let server_cert_info = CertificateParams::builder() //! .subject(server_subject) //! .subject_public_key(certkit::key::PublicKey::from_key_pair(&server_key)) //! .build(); //! -//! let validity = Validity::for_days(365); -//! let server_cert = ca_with_key.issue(&server_cert_info, validity); +//! let validity = Validity::for_days(365)?; +//! let server_cert = ca_with_key.issue(&server_cert_info, validity)?; //! //! println!("Server certificate issued successfully!"); //! # Ok(()) @@ -119,7 +125,7 @@ //! key::KeyPair, //! cert::{ //! Certificate, -//! params::{CertificationRequestInfo, DistinguishedName, ExtensionParam}, +//! params::{CertificateParams, DistinguishedName, ExtensionParam}, //! extensions::{SubjectAltName, ExtendedKeyUsage, ExtendedKeyUsageOption, ToAndFromX509Extension}, //! }, //! }; @@ -129,7 +135,8 @@ //! //! // Create Subject Alternative Name extension //! let san = SubjectAltName { -//! names: vec!["example.com".to_string(), "www.example.com".to_string()], +//! dns_names: vec!["example.com".to_string(), "www.example.com".to_string()], +//! ..Default::default() //! }; //! //! // Create Extended Key Usage extension @@ -141,16 +148,16 @@ //! .common_name("example.com".to_string()) //! .build(); //! -//! let cert_info = CertificationRequestInfo::builder() +//! let cert_info = CertificateParams::builder() //! .subject(subject) //! .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) //! .extensions(vec![ -//! ExtensionParam::from_extension(san, false), -//! ExtensionParam::from_extension(eku, true), +//! ExtensionParam::from_extension(san, false)?, +//! ExtensionParam::from_extension(eku, true)?, //! ]) //! .build(); //! -//! let certificate = Certificate::new_self_signed(&cert_info, &key_pair); +//! let certificate = Certificate::new_self_signed(&cert_info, &key_pair)?; //! println!("Certificate with extensions created successfully!"); //! # Ok(()) //! # } @@ -193,3 +200,12 @@ pub mod error; pub mod issuer; pub mod key; pub mod tbs_certificate; + +/// Initializes `env_logger` for unit tests. Defaults to the `error` level so +/// the suite is quiet, but `RUST_LOG` still overrides it. +#[cfg(test)] +pub(crate) fn init_test_logger() { + let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("error")) + .is_test(true) + .try_init(); +} diff --git a/src/tbs_certificate.rs b/src/tbs_certificate.rs index e8bbacd..a7841d5 100644 --- a/src/tbs_certificate.rs +++ b/src/tbs_certificate.rs @@ -1,3 +1,5 @@ +//! Low-level "To Be Signed" certificate structure. + use crate::error::CertKitError; use der::Encode; use der::asn1::OctetString; @@ -28,10 +30,10 @@ pub struct TbsCertificate { pub signature_algorithm: SignatureAlgorithm, /// Certificate issuer distinguished name pub issuer: DistinguishedName, - /// Not before time (in seconds since Unix epoch) - pub not_before: time::OffsetDateTime, - /// Not after time (in seconds since Unix epoch) - pub not_after: time::OffsetDateTime, + /// Start of the certificate's validity period + pub not_before: x509_cert::time::Time, + /// End of the certificate's validity period + pub not_after: x509_cert::time::Time, /// Certificate subject distinguished name pub subject: DistinguishedName, /// Subject's public key @@ -41,41 +43,8 @@ pub struct TbsCertificate { } impl TbsCertificate { - /// Creates a new `TbsCertificate` with default values. - /// - /// # Arguments - /// * `issuer` - The distinguished name of the certificate issuer. - /// * `subject` - The distinguished name of the certificate subject. - /// * `subject_public_key` - The public key of the certificate subject. - /// * `signature_algorithm` - The algorithm used to sign the certificate. - /// * `extensions` - Additional X.509 extensions for the certificate. - pub fn new( - issuer: DistinguishedName, - subject: DistinguishedName, - subject_public_key: PublicKey, - signature_algorithm: SignatureAlgorithm, - extensions: Vec, - ) -> Self { - let not_before = time::OffsetDateTime::now_utc(); - let not_after = not_before + time::Duration::days(365); - - Self { - serial_number: vec![1], - signature_algorithm, - issuer, - not_before, - not_after, - subject, - subject_public_key, - extensions, - } - } - /// Converts the `TbsCertificate` into a `TbsCertificateInner` for DER encoding. - /// - /// # Returns - /// A `TbsCertificateInner` object suitable for DER encoding. - pub fn to_tbs_certificate_inner(&self) -> TbsCertificateInner { + pub fn to_tbs_certificate_inner(&self) -> Result { // Convert to x509_cert's format let algorithm_id: x509_cert::spki::AlgorithmIdentifierOwned = self.signature_algorithm.clone().into(); @@ -84,72 +53,40 @@ impl TbsCertificate { let extensions = self .extensions .iter() - .map(|ext| x509_cert::ext::Extension { - extn_id: ext.oid, - critical: ext.critical, - extn_value: OctetString::new(ext.value.clone()).unwrap(), + .map(|ext| { + Ok(x509_cert::ext::Extension { + extn_id: ext.oid, + critical: ext.critical, + extn_value: OctetString::new(ext.value.clone()).map_err(|e| { + CertKitError::EncodingError(format!("extension value: {e}")) + })?, + }) }) - .collect::>(); - - // Create validity - let not_before = x509_cert::time::Time::UtcTime( - der::asn1::UtcTime::from_system_time(self.not_before.into()).unwrap(), - ); - let not_after = x509_cert::time::Time::UtcTime( - der::asn1::UtcTime::from_system_time(self.not_after.into()).unwrap(), - ); + .collect::, CertKitError>>()?; let validity = x509_cert::time::Validity { - not_before, - not_after, + not_before: self.not_before, + not_after: self.not_after, }; - // Create SerialNumber - let serial_number = SerialNumber::new(self.serial_number.as_slice()).unwrap(); + let serial_number = SerialNumber::new(self.serial_number.as_slice()) + .map_err(|e| CertKitError::InvalidInput(format!("invalid serial number: {e}")))?; // Convert the subject public key to SPKI format - let subject_public_key_info = match &self.subject_public_key { - #[cfg(feature = "rsa")] - PublicKey::Rsa(public) => { - x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(public.clone()).unwrap() - } - #[cfg(feature = "p256")] - PublicKey::EcdsaP256(verifying_key) => { - x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key).unwrap() - } - #[cfg(feature = "p384")] - PublicKey::EcdsaP384(verifying_key) => { - x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key).unwrap() - } - #[cfg(feature = "p521")] - PublicKey::EcdsaP521(verifying_key) => { - x509_cert::spki::SubjectPublicKeyInfoOwned::from_key(*verifying_key).unwrap() - } - #[cfg(feature = "ed25519")] - PublicKey::Ed25519(verifying_key) => { - let pk_bytes = verifying_key.to_bytes(); - x509_cert::spki::SubjectPublicKeyInfoOwned { - algorithm: x509_cert::spki::AlgorithmIdentifierOwned { - oid: const_oid::ObjectIdentifier::new_unwrap("1.3.101.112"), - parameters: None, - }, - subject_public_key: der::asn1::BitString::from_bytes(&pk_bytes).unwrap(), - } - } - }; + let subject_public_key_info = self.subject_public_key.as_spki(); - TbsCertificateInner { + Ok(TbsCertificateInner { version: Version::V3, serial_number, signature: algorithm_id, - issuer: self.issuer.as_x509_name(), + issuer: self.issuer.as_x509_name()?, validity, - subject: self.subject.as_x509_name(), + subject: self.subject.as_x509_name()?, subject_public_key_info, issuer_unique_id: None, subject_unique_id: None, extensions: Some(extensions), - } + }) } /// Creates a `TbsCertificate` from a `TbsCertificateInner`. @@ -161,8 +98,8 @@ impl TbsCertificate { /// A `TbsCertificate` object. pub fn from_tbs_certificate_inner(inner: TbsCertificateInner) -> Result { // Convert from x509_cert's format - let issuer = DistinguishedName::from_x509_name(&inner.issuer); - let subject = DistinguishedName::from_x509_name(&inner.subject); + let issuer = DistinguishedName::from_x509_name(&inner.issuer)?; + let subject = DistinguishedName::from_x509_name(&inner.subject)?; let subject_public_key = PublicKey::from_x509spki(&inner.subject_public_key_info)?; // Convert extensions @@ -177,28 +114,15 @@ impl TbsCertificate { }) .collect::>(); - // Get timestamps from validity - let not_before = match inner.validity.not_before { - x509_cert::time::Time::UtcTime(ut) => time::OffsetDateTime::from(ut.to_system_time()), - x509_cert::time::Time::GeneralTime(gt) => { - time::OffsetDateTime::from(gt.to_system_time()) - } - }; - - let not_after = match inner.validity.not_after { - x509_cert::time::Time::UtcTime(ut) => time::OffsetDateTime::from(ut.to_system_time()), - x509_cert::time::Time::GeneralTime(gt) => { - time::OffsetDateTime::from(gt.to_system_time()) - } - }; - // Determine signature algorithm based on OID let signature_algorithm = match inner.signature.oid { const_oid::db::rfc5912::SHA_256_WITH_RSA_ENCRYPTION => { SignatureAlgorithm::Sha256WithRSA } const_oid::db::rfc5912::ECDSA_WITH_SHA_256 => SignatureAlgorithm::Sha256WithECDSA, - const_oid::db::rfc8410::ID_ED_25519 => SignatureAlgorithm::Sha256WithEdDSA, + const_oid::db::rfc5912::ECDSA_WITH_SHA_384 => SignatureAlgorithm::Sha384WithECDSA, + const_oid::db::rfc5912::ECDSA_WITH_SHA_512 => SignatureAlgorithm::Sha512WithECDSA, + const_oid::db::rfc8410::ID_ED_25519 => SignatureAlgorithm::Ed25519, _ => { return Err(CertKitError::DecodingError( "Unsupported signature algorithm".to_string(), @@ -210,8 +134,8 @@ impl TbsCertificate { serial_number: inner.serial_number.as_bytes().into(), signature_algorithm, issuer, - not_before, - not_after, + not_before: inner.validity.not_before, + not_after: inner.validity.not_after, subject, subject_public_key, extensions, @@ -222,7 +146,59 @@ impl TbsCertificate { /// /// # Returns /// A byte vector containing the DER-encoded certificate. - pub fn to_der(&self) -> Result, der::Error> { - self.to_tbs_certificate_inner().to_der() + pub fn to_der(&self) -> Result, CertKitError> { + self.to_tbs_certificate_inner()? + .to_der() + .map_err(|e| CertKitError::EncodingError(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cert::Certificate; + use crate::cert::params::{CertificateParams, DistinguishedName}; + use crate::key::{KeyPair, PublicKey}; + + fn self_signed(key: &KeyPair) -> Certificate { + let request = CertificateParams::builder() + .subject( + DistinguishedName::builder() + .common_name("parse.test".to_string()) + .build(), + ) + .subject_public_key(PublicKey::from_key_pair(key)) + .build(); + Certificate::new_self_signed(&request, key).unwrap() + } + + // The OID -> SignatureAlgorithm parse path must recognize the per-curve ECDSA + // OIDs; P-384/P-521 certificates previously failed to parse with + // "Unsupported signature algorithm". + + #[cfg(feature = "p384")] + #[test] + fn parses_ecdsa_p384_signature_algorithm() { + let cert = self_signed(&KeyPair::generate_ecdsa_p384()); + let parsed = + TbsCertificate::from_tbs_certificate_inner(cert.inner().tbs_certificate.clone()) + .expect("a P-384 certificate must parse"); + assert!(matches!( + parsed.signature_algorithm, + SignatureAlgorithm::Sha384WithECDSA + )); + } + + #[cfg(feature = "p521")] + #[test] + fn parses_ecdsa_p521_signature_algorithm() { + let cert = self_signed(&KeyPair::generate_ecdsa_p521()); + let parsed = + TbsCertificate::from_tbs_certificate_inner(cert.inner().tbs_certificate.clone()) + .expect("a P-521 certificate must parse"); + assert!(matches!( + parsed.signature_algorithm, + SignatureAlgorithm::Sha512WithECDSA + )); } } diff --git a/tests/botan.rs b/tests/botan.rs deleted file mode 100644 index 625cbbc..0000000 --- a/tests/botan.rs +++ /dev/null @@ -1,71 +0,0 @@ -use botan::Certificate as BotanCertificate; - -use certkit::cert::Certificate; -use certkit::cert::params::{CertificationRequestInfo, DistinguishedName}; -use certkit::key::KeyPair; - -fn default_params() -> (CertificationRequestInfo, KeyPair) { - let key_pair = KeyPair::generate_ecdsa_p256(); - let subject = DistinguishedName::builder() - .common_name("crabs.crabs".to_string()) - .organization("Crab widgits SE".to_string()) - .build(); - let cert_info = CertificationRequestInfo::builder() - .subject(subject) - .subject_public_key(certkit::key::PublicKey::from_key_pair(&key_pair)) - .build(); - (cert_info, key_pair) -} - -fn check_cert(cert_der: &[u8]) { - // Use botan crate to parse the DER and assert it succeeds - BotanCertificate::load(cert_der).expect("Botan failed to parse certificate"); -} - -#[test] -#[ignore] -fn test_botan_ecdsa_p256() { - let (params, key_pair) = default_params(); - let cert = Certificate::new_self_signed(¶ms, &key_pair); - check_cert(&cert.to_der().unwrap()); -} - -#[test] -#[ignore] -fn test_botan_ed25519() { - let mut params = default_params().0; - let key_pair = KeyPair::generate_ed25519(); - params.subject_public_key = certkit::key::PublicKey::from_key_pair(&key_pair); - let cert = Certificate::new_self_signed(¶ms, &key_pair); - check_cert(&cert.to_der().unwrap()); -} - -#[test] -#[ignore] -fn test_botan_ecdsa_p384() { - let mut params = default_params().0; - let key_pair = KeyPair::generate_ecdsa_p384(); - params.subject_public_key = certkit::key::PublicKey::from_key_pair(&key_pair); - let cert = Certificate::new_self_signed(¶ms, &key_pair); - check_cert(&cert.to_der().unwrap()); -} - -#[test] -#[ignore] -fn test_botan_ecdsa_p521() { - let mut params = default_params().0; - let key_pair = KeyPair::generate_ecdsa_p521(); - params.subject_public_key = certkit::key::PublicKey::from_key_pair(&key_pair); - let cert = Certificate::new_self_signed(¶ms, &key_pair); - check_cert(&cert.to_der().unwrap()); -} - -#[test] -#[ignore] -fn test_botan_rsa() { - let mut params = default_params().0; - let key_pair = KeyPair::generate_rsa(2048).unwrap(); - params.subject_public_key = certkit::key::PublicKey::from_key_pair(&key_pair); - let cert = Certificate::new_self_signed(¶ms, &key_pair); - check_cert(&cert.to_der().unwrap()); -} diff --git a/tests/openssl.rs b/tests/openssl.rs deleted file mode 100644 index a02faf6..0000000 --- a/tests/openssl.rs +++ /dev/null @@ -1,186 +0,0 @@ -#[cfg(feature = "p256")] //For now these tests only test with p256 -mod test { - use crate::util; - use certkit::cert::extensions::ExtendedKeyUsageOption; - use certkit::cert::params::{CertificationRequestInfo, DistinguishedName, Validity}; - use certkit::issuer::Issuer; - use certkit::key::{KeyPair, PublicKey}; - use regex::Regex; - use std::fs; - use std::process::Command; - use time::OffsetDateTime; - - #[test] - fn test_openssl_validate_cert() { - // Generate a CA certificate - let ca_cert_with_key = util::generate_ca_cert(); - - // Generate a server certificate signed by the CA - let server_key = KeyPair::generate_ecdsa_p256(); - let server_dn = DistinguishedName::builder() - .common_name("server.myca.local".to_string()) - .build(); - - let server_public_key = PublicKey::from_key_pair(&server_key); - let server_cert_info = CertificationRequestInfo::builder() - .subject(server_dn) - .subject_public_key(server_public_key) - .usages(vec![ExtendedKeyUsageOption::ServerAuth]) - .build(); - - let validity = Validity { - not_before: OffsetDateTime::now_utc(), - not_after: OffsetDateTime::now_utc() + time::Duration::days(365), - }; - let server_cert = ca_cert_with_key.issue(&server_cert_info, validity); - let server_cert_pem = server_cert.to_pem().unwrap(); - - // Save the certificate to a temporary file - let cert_path = "/tmp/test_server_cert.pem"; - fs::write(cert_path, server_cert_pem).expect("Failed to write server certificate"); - - // Use OpenSSL CLI to validate the generated certificate - let output = Command::new("openssl") - .arg("x509") - .arg("-in") - .arg(cert_path) - .arg("-noout") - .arg("-text") - //If this is not added then the output of openssl is in the users system language which breaks this test. - .env("LANG", "C") - .output() - .expect("Failed to execute OpenSSL command"); - - // Check if OpenSSL command was successful - assert!( - output.status.success(), - "OpenSSL command failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - // Updated test to validate static fields and use partial matching for dynamic fields - let output_text = String::from_utf8_lossy(&output.stdout); - println!("output_text {output_text}"); - - // Validate static fields - // Note: Different openssl versions format this field differently! - assert!( - output_text.contains("Issuer: C=, ST=, L=, O=, OU=, CN=myca.local") - || output_text.contains("Issuer: C = , ST = , L = , O = , OU = , CN = myca.local"), - "Issuer field is incorrect" - ); - - // Note: Different openssl versions format this field differently! - assert!( - output_text.contains("Subject: C=, ST=, L=, O=, OU=, CN=server.myca.local") - || output_text - .contains("Subject: C = , ST = , L = , O = , OU = , CN = server.myca.local"), - "Subject field is incorrect" - ); - assert!( - output_text.contains("Version: 3 (0x2)"), - "Version field is incorrect" - ); - assert!( - output_text.contains("Serial Number: 1 (0x1)"), - "Serial Number field is incorrect" - ); - - // Validate dynamic fields with regex - let not_before_regex = Regex::new(r"Not Before: .+").unwrap(); - let not_after_regex = Regex::new(r"Not After : .+").unwrap(); - - assert!( - not_before_regex.is_match(&output_text), - "Missing or incorrect Not Before field" - ); - assert!( - not_after_regex.is_match(&output_text), - "Missing or incorrect Not After field" - ); - assert!( - output_text.contains("Signature Algorithm: ecdsa-with-SHA256"), - "Signature Algorithm field is incorrect" - ); - - // Clean up temporary files - fs::remove_file(cert_path).expect("Failed to remove test certificate"); - } - - #[test] - fn test_openssl_crate_validate_cert() { - // Generate a CA certificate - let ca_cert_with_key = util::generate_ca_cert(); - - // Generate a server certificate signed by the CA - let server_key = KeyPair::generate_ecdsa_p256(); - let server_dn = DistinguishedName::builder() - .common_name("server.myca.local".to_string()) - .build(); - - let server_public_key = PublicKey::from_key_pair(&server_key); - let server_cert_info = CertificationRequestInfo::builder() - .subject(server_dn) - .subject_public_key(server_public_key) - .usages(vec![ExtendedKeyUsageOption::ServerAuth]) - .build(); - - let validity = Validity { - not_before: OffsetDateTime::now_utc(), - not_after: OffsetDateTime::now_utc() + time::Duration::days(365), - }; - let server_cert = ca_cert_with_key.issue(&server_cert_info, validity); - let server_cert_pem = server_cert.to_pem().unwrap(); - - // Use the openssl crate to parse and validate the certificate - use openssl::x509::X509; - let x509 = X509::from_pem(server_cert_pem.as_bytes()).expect("Failed to parse PEM"); - - // Check subject - let subject = x509 - .subject_name() - .entries_by_nid(openssl::nid::Nid::COMMONNAME) - .next() - .unwrap() - .data() - .as_utf8() - .unwrap(); - assert_eq!( - subject.to_string(), - "server.myca.local", - "Subject CN mismatch" - ); - - // Check issuer - let issuer = x509 - .issuer_name() - .entries_by_nid(openssl::nid::Nid::COMMONNAME) - .next() - .unwrap() - .data() - .as_utf8() - .unwrap(); - assert_eq!(issuer.to_string(), "myca.local", "Issuer CN mismatch"); - - // Check version - assert_eq!( - x509.version(), - 2, - "X509 version should be 3 (0-based index)" - ); - - // Check serial number - let serial = x509.serial_number().to_bn().unwrap().to_dec_str().unwrap(); - assert_eq!(serial.to_string(), "1", "Serial number should be 1"); - - // Check signature algorithm - let sig_alg = x509.signature_algorithm().object().nid(); - - assert_eq!( - sig_alg, - openssl::nid::Nid::ECDSA_WITH_SHA256, - "Signature algorithm should be ecdsa-with-SHA256" - ); - } -} -mod util; diff --git a/tests/test.rs b/tests/test.rs deleted file mode 100644 index 99cb2e1..0000000 --- a/tests/test.rs +++ /dev/null @@ -1,109 +0,0 @@ -mod util; -#[cfg(feature = "p256")] -mod test { - use certkit::cert::params; - use certkit::error::CertKitError; - use certkit::{ - cert::params::{CertificationRequestInfo, DistinguishedName}, - issuer::Issuer, - key::KeyPair, - }; - pub type Result = std::result::Result; - use crate::util; - use time::OffsetDateTime; - - /// Generates a Certificate Authority (CA) certificate and saves it as a PEM file. - /// This test ensures the CA certificate generation process works as expected. - #[test] - fn generate_ca_cert() -> Result<()> { - let ca_cert_with_key = util::generate_ca_cert(); - - use std::io::Write; - std::fs::create_dir_all(".debug_certs").unwrap(); - std::fs::File::create(".debug_certs/ca_cert.pem") - .unwrap() - .write_all(ca_cert_with_key.cert.to_pem().unwrap().as_bytes()) - .unwrap(); - - eprintln!("CA Certificate: {:?}", ca_cert_with_key.cert); - Ok(()) - } - - /// Generates a server certificate signed by the CA and saves it as a PEM file. - /// This test ensures the server certificate generation and signing process works as expected. - #[test] - fn generate_server_cert() -> Result<()> { - let ca_cert_with_key = util::generate_ca_cert(); - - let server_key = KeyPair::generate_ecdsa_p256(); - let server_dn = DistinguishedName::builder() - .common_name("server.myca.local".to_string()) - .build(); - - let server_public_key = certkit::key::PublicKey::from_key_pair(&server_key); - let server_cert_info = CertificationRequestInfo::builder() - .subject(server_dn) - .subject_public_key(server_public_key) - .usages(vec![ - certkit::cert::extensions::ExtendedKeyUsageOption::ServerAuth, - ]) - .build(); - - let validity = params::Validity { - not_before: OffsetDateTime::now_utc(), - not_after: OffsetDateTime::now_utc() + time::Duration::days(365), - }; - let server_cert = ca_cert_with_key.issue(&server_cert_info, validity); - - eprintln!("Server Certificate: {server_cert:?}"); - let server_cert_pem = server_cert.to_pem().unwrap(); - - use std::io::Write; - std::fs::create_dir_all(".debug_certs").unwrap(); - std::fs::File::create(".debug_certs/server_cert.pem") - .unwrap() - .write_all(server_cert_pem.as_bytes()) - .unwrap(); - - Ok(()) - } - - /// Generates a client certificate signed by the CA and saves it as a PEM file. - /// This test ensures the client certificate generation and signing process works as expected. - #[test] - fn generate_client_cert() -> Result<()> { - let ca_cert_with_key = util::generate_ca_cert(); - - let client_key = KeyPair::generate_ecdsa_p256(); - let client_dn = DistinguishedName::builder() - .common_name("client.myca.local".to_string()) - .build(); - - let client_public_key = certkit::key::PublicKey::from_key_pair(&client_key); - let client_cert_info = CertificationRequestInfo::builder() - .subject(client_dn) - .subject_public_key(client_public_key) - .usages(vec![ - certkit::cert::extensions::ExtendedKeyUsageOption::ClientAuth, - ]) - .build(); - - let validity = params::Validity { - not_before: OffsetDateTime::now_utc(), - not_after: OffsetDateTime::now_utc() + time::Duration::days(365), - }; - let client_cert = ca_cert_with_key.issue(&client_cert_info, validity); - - eprintln!("Client Certificate: {client_cert:?}"); - let client_cert_pem = client_cert.to_pem().unwrap(); - - use std::io::Write; - std::fs::create_dir_all(".debug_certs").unwrap(); - std::fs::File::create(".debug_certs/client_cert.pem") - .unwrap() - .write_all(client_cert_pem.as_bytes()) - .unwrap(); - - Ok(()) - } -} diff --git a/tests/tls_echo.rs b/tests/tls_echo.rs new file mode 100644 index 0000000..287b819 --- /dev/null +++ b/tests/tls_echo.rs @@ -0,0 +1,220 @@ +//! End-to-end integration test: builds a full PKI chain with `certkit`, then +//! stands up a sync rustls echo server (with mTLS) and a rustls client, and +//! verifies a successful echo round-trip over the encrypted channel. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::thread; + +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use rustls::server::WebPkiClientVerifier; +use rustls::{ + ClientConfig, ClientConnection, RootCertStore, ServerConfig, ServerConnection, StreamOwned, +}; + +use certkit::cert::extensions::{ExtendedKeyUsageOption, SubjectAltName}; +use certkit::cert::params::{CertificateParams, DistinguishedName, ExtensionParam, Validity}; +use certkit::cert::{Certificate, CertificateWithPrivateKey}; +use certkit::issuer::Issuer; +use certkit::key::{KeyPair, PublicKey}; + +/// Generate a self-signed root CA certificate. +fn generate_ca(gen_key: &dyn Fn() -> KeyPair) -> CertificateWithPrivateKey { + let key = gen_key(); + let params = CertificateParams::builder() + .subject( + DistinguishedName::builder() + .common_name("Test Root CA".to_string()) + .build(), + ) + .subject_public_key(PublicKey::from_key_pair(&key)) + .is_ca(true) + .build(); + let cert = Certificate::new_self_signed(¶ms, &key).unwrap(); + CertificateWithPrivateKey::new(cert, key) +} + +/// Issue an intermediate CA certificate signed by `parent`. +fn generate_intermediate( + parent: &CertificateWithPrivateKey, + gen_key: &dyn Fn() -> KeyPair, +) -> CertificateWithPrivateKey { + let key = gen_key(); + let params = CertificateParams::builder() + .subject( + DistinguishedName::builder() + .common_name("Test Intermediate CA".to_string()) + .build(), + ) + .subject_public_key(PublicKey::from_key_pair(&key)) + .is_ca(true) + .build(); + let cert = parent + .issue(¶ms, Validity::for_days(1).unwrap()) + .unwrap(); + CertificateWithPrivateKey::new(cert, key) +} + +/// Issue an end-entity certificate from an issuing CA. +/// +/// `dns_name` is used as both the Subject CN and the sole SAN DNS entry, +/// matching the modern convention where CN mirrors the primary SAN. +fn issue_end_entity( + issuer: &CertificateWithPrivateKey, + gen_key: &dyn Fn() -> KeyPair, + dns_name: &str, + usage: ExtendedKeyUsageOption, +) -> CertificateWithPrivateKey { + let key = gen_key(); + let san = SubjectAltName { + dns_names: vec![dns_name.to_string()], + ..Default::default() + }; + let params = CertificateParams::builder() + .subject( + DistinguishedName::builder() + .common_name(dns_name.to_string()) + .build(), + ) + .subject_public_key(PublicKey::from_key_pair(&key)) + .usages(vec![usage]) + .extensions(vec![ExtensionParam::from_extension(san, false).unwrap()]) + .build(); + let cert = issuer + .issue(¶ms, Validity::for_days(1).unwrap()) + .unwrap(); + CertificateWithPrivateKey::new(cert, key) +} + +/// Core mTLS echo test parameterised by key generation function. +/// +/// Builds: Root CA -> Intermediate CA -> Server cert + Client cert, +/// then runs a TLS echo server and client on localhost. +fn run_mtls_echo(gen_key: impl Fn() -> KeyPair) { + let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("error")) + .is_test(true) + .try_init(); + + let keygen = &gen_key; + + // 1. Build the PKI chain + let root_ca = generate_ca(keygen); + let intermediate_ca = generate_intermediate(&root_ca, keygen); + + let server = issue_end_entity( + &intermediate_ca, + keygen, + "localhost", + ExtendedKeyUsageOption::ServerAuth, + ); + let client = issue_end_entity( + &intermediate_ca, + keygen, + "client.local", + ExtendedKeyUsageOption::ClientAuth, + ); + + // 2. Prepare DER materials for rustls + let root_der = CertificateDer::from(root_ca.cert().to_der().unwrap()); + let int_der = CertificateDer::from(intermediate_ca.cert().to_der().unwrap()); + let server_der = CertificateDer::from(server.cert().to_der().unwrap()); + let client_der = CertificateDer::from(client.cert().to_der().unwrap()); + + let server_chain = vec![server_der, int_der.clone()]; + let client_chain = vec![client_der, int_der]; + + let server_key = + PrivateKeyDer::try_from(server.key().encode_private_key_der().unwrap()).unwrap(); + let client_key = + PrivateKeyDer::try_from(client.key().encode_private_key_der().unwrap()).unwrap(); + + // 3. Configure rustls server (mTLS) + let mut root_store = RootCertStore::empty(); + root_store.add(root_der).expect("add root CA"); + + let client_verifier = WebPkiClientVerifier::builder(Arc::new(root_store.clone())) + .build() + .expect("build client verifier"); + + let server_config = Arc::new( + ServerConfig::builder() + .with_client_cert_verifier(client_verifier) + .with_single_cert(server_chain, server_key) + .expect("build server config"), + ); + + // 4. Configure rustls client + let client_config = Arc::new( + ClientConfig::builder() + .with_root_certificates(root_store) + .with_client_auth_cert(client_chain, client_key) + .expect("build client config"), + ); + + // 5. Bind TCP listener + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + // 6. Spawn server thread + let server_cfg = server_config.clone(); + let server_handle = thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept"); + let conn = ServerConnection::new(server_cfg).expect("server conn"); + let mut tls = StreamOwned::new(conn, stream); + + let mut buf = vec![0u8; 1024]; + let n = tls.read(&mut buf).expect("server read"); + tls.write_all(&buf[..n]).expect("server write"); + tls.conn.send_close_notify(); + tls.conn.write_tls(&mut tls.sock).ok(); + }); + + // 7. Client: connect, send, receive, assert + let stream = TcpStream::connect(addr).expect("connect"); + let server_name = ServerName::try_from("localhost").expect("server name"); + let conn = ClientConnection::new(client_config, server_name).expect("client conn"); + let mut tls = StreamOwned::new(conn, stream); + + let message = b"certkit echo test"; + tls.write_all(message).expect("client write"); + tls.flush().expect("client flush"); + + let mut response = vec![0u8; message.len()]; + tls.read_exact(&mut response).expect("client read"); + + assert_eq!( + &response, message, + "echo mismatch: expected {:?}, got {:?}", + message, response + ); + + server_handle.join().expect("server thread panicked"); +} + +// Per-algorithm test variants +// rustls/webpki does not support ECDSA-P521 verification. + +#[cfg(feature = "p256")] +#[test] +fn mtls_echo_p256() { + run_mtls_echo(KeyPair::generate_ecdsa_p256); +} + +#[cfg(feature = "p384")] +#[test] +fn mtls_echo_p384() { + run_mtls_echo(KeyPair::generate_ecdsa_p384); +} + +#[cfg(feature = "ed25519")] +#[test] +fn mtls_echo_ed25519() { + run_mtls_echo(KeyPair::generate_ed25519); +} + +#[cfg(feature = "rsa")] +#[test] +fn mtls_echo_rsa() { + run_mtls_echo(|| KeyPair::generate_rsa(2048).expect("rsa keygen")); +} diff --git a/tests/util.rs b/tests/util.rs deleted file mode 100644 index 391edd3..0000000 --- a/tests/util.rs +++ /dev/null @@ -1,35 +0,0 @@ -#[cfg(feature = "p256")] -mod impl_p256 { - use certkit::cert::extensions::ExtendedKeyUsageOption; - use certkit::cert::params::{CertificationRequestInfo, DistinguishedName}; - use certkit::cert::{Certificate, CertificateWithPrivateKey}; - use certkit::key::{KeyPair, PublicKey}; - - pub fn generate_ca_cert() -> CertificateWithPrivateKey { - let ca_key = KeyPair::generate_ecdsa_p256(); - - let subject_dn = DistinguishedName::builder() - .common_name("myca.local".to_string()) - .build(); - - let subject_public_key = PublicKey::from_key_pair(&ca_key); - - let ca_cert_info = CertificationRequestInfo::builder() - .subject(subject_dn.clone()) - .subject_public_key(subject_public_key) - .usages(vec![ - ExtendedKeyUsageOption::ServerAuth, - ExtendedKeyUsageOption::ClientAuth, - ]) - .extensions(vec![]) - .build(); - - CertificateWithPrivateKey { - cert: Certificate::new_self_signed(&ca_cert_info, &ca_key), - key: ca_key, - } - } -} - -#[cfg(feature = "p256")] -pub use impl_p256::generate_ca_cert;