est-ca 0.2.0

RFC 7030 Enrollment over Secure Transport (EST) — client, server, and an internal X.509 CA in pure Rust.
//! Issued-certificate profile — the constraints every leaf cert this
//! CA signs MUST satisfy. The profile is applied at signing time; any
//! extension not named here is stripped from the incoming CSR before
//! signing to prevent extension-injection attacks.

use std::time::Duration;

/// Declarative leaf-certificate profile.
///
/// The defaults target **short-lived client-auth certificates** — the
/// common shape for an internal PKI that issues identities to
/// programmatic callers. Adjust via the builder methods (or construct
/// directly) for different use cases.
#[derive(Debug, Clone)]
pub struct CertProfile {
    /// Certificate validity period (e.g. 7 days).
    pub validity: Duration,
    /// Populate `ExtKeyUsage::ClientAuth`.
    pub client_auth: bool,
    /// Populate `ExtKeyUsage::ServerAuth`. Off by default since the
    /// primary use case is client identity, not serving TLS.
    pub server_auth: bool,
    /// Maximum permitted length (in bytes) of the Subject CommonName.
    pub max_cn_bytes: usize,
}

impl Default for CertProfile {
    fn default() -> Self {
        Self {
            validity: Duration::from_secs(7 * 24 * 60 * 60),
            client_auth: true,
            server_auth: false,
            max_cn_bytes: 128,
        }
    }
}

/// Lower bound on [`CertProfile::validity`] — a cert with less than a
/// minute of life is almost certainly a misconfiguration.
pub const MIN_VALIDITY: Duration = Duration::from_secs(60);

/// Upper bound on [`CertProfile::validity`] — short-lived certs are the
/// revocation story, so anything above five years is rejected as a
/// strong signal that the profile is being misused.
pub const MAX_VALIDITY: Duration = Duration::from_secs(5 * 365 * 86_400);

impl CertProfile {
    /// New profile with validity `days` and client-auth EKU only.
    ///
    /// # Panics
    ///
    /// Panics if `days` maps to a validity outside
    /// [`MIN_VALIDITY`]..=[`MAX_VALIDITY`]. If you need a profile
    /// outside that range, construct [`CertProfile`] by hand and check
    /// it via [`validate`](Self::validate).
    pub fn client_auth_for_days(days: u64) -> Self {
        let profile = Self { validity: Duration::from_secs(days * 86_400), ..Self::default() };
        profile.validate().expect("validity out of range");
        profile
    }

    /// Check the profile's numeric bounds. Returns an explanatory error
    /// if [`Self::validity`] falls outside
    /// [`MIN_VALIDITY`]..=[`MAX_VALIDITY`].
    pub fn validate(&self) -> Result<(), String> {
        if self.validity < MIN_VALIDITY {
            return Err(format!(
                "validity {:?} < MIN_VALIDITY {:?}",
                self.validity, MIN_VALIDITY
            ));
        }
        if self.validity > MAX_VALIDITY {
            return Err(format!(
                "validity {:?} > MAX_VALIDITY {:?}",
                self.validity, MAX_VALIDITY
            ));
        }
        Ok(())
    }

    /// Validate a principal id (enforced as the Subject CN on the issued
    /// cert) against the profile's constraints.
    pub fn validate_cn(&self, cn: &str) -> Result<(), String> {
        if cn.is_empty() {
            return Err("CN must not be empty".into());
        }
        if cn.as_bytes().len() > self.max_cn_bytes {
            return Err(format!("CN exceeds {} bytes", self.max_cn_bytes));
        }
        if cn.chars().any(|c| c.is_control()) {
            return Err("CN contains control characters".into());
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validate_cn_rejects_empty_oversize_and_control_chars() {
        let p = CertProfile::default();
        assert!(p.validate_cn("gateway-001").is_ok());
        assert!(p.validate_cn("").is_err());
        let huge = "x".repeat(p.max_cn_bytes + 1);
        assert!(p.validate_cn(&huge).is_err());
        assert!(p.validate_cn("bad\x07cn").is_err());
    }

    #[test]
    fn validate_rejects_zero_and_decade_long_validity() {
        let mut p = CertProfile::default();
        p.validity = Duration::from_secs(0);
        assert!(p.validate().is_err(), "zero validity must be rejected");
        p.validity = Duration::from_secs(10 * 365 * 86_400);
        assert!(p.validate().is_err(), "ten-year validity must be rejected");
        p.validity = Duration::from_secs(30 * 86_400);
        assert!(p.validate().is_ok(), "30 days must be accepted");
    }

    #[test]
    #[should_panic(expected = "validity out of range")]
    fn client_auth_for_days_panics_on_zero() {
        let _ = CertProfile::client_auth_for_days(0);
    }
}