est-ca 0.1.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,
        }
    }
}

impl CertProfile {
    /// New profile with validity `days` and client-auth EKU only.
    pub fn client_auth_for_days(days: u64) -> Self {
        Self { validity: Duration::from_secs(days * 86_400), ..Self::default() }
    }

    /// 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());
    }
}