est-ca 0.2.0

RFC 7030 Enrollment over Secure Transport (EST) — client, server, and an internal X.509 CA in pure Rust.
//! RFC 7030 EST client — generate a CSR, enroll, and parse the issued
//! certificate out of the PKCS#7 response.
//!
//! Renewal (`simplereenroll`) requires an mTLS-capable HTTP client
//! built with the previously-issued cert/key. The consumer supplies that
//! client; this module keeps the transport details pluggable.

use base64::{engine::general_purpose::STANDARD, Engine as _};
use rcgen::{CertificateParams, DnType, KeyPair};

use crate::cms::{decode_degenerate_first, decode_leaf_from_degenerate};
use crate::error::{Error, Result};
use crate::est::{CONTENT_TYPE_PKCS10, WELL_KNOWN_PREFIX};

/// Output of a successful enrollment: DER-encoded leaf cert + the
/// PEM-encoded private key the agent generated locally. The caller is
/// responsible for persisting both with appropriate filesystem perms.
pub struct Enrollment {
    /// Issued leaf certificate, DER-encoded.
    pub cert_der: Vec<u8>,
    /// Private key the CSR was signed with, PEM-encoded (PKCS#8).
    pub key_pem: String,
}

/// Generate a new P-256 keypair + CSR with the given `common_name`.
///
/// Returns `(csr_der, key_pem)` — the CSR is what you send to the EST
/// server; the key PEM must be retained by the caller so the cert is
/// usable after issuance.
pub fn make_csr(common_name: &str) -> Result<(Vec<u8>, String)> {
    let key = KeyPair::generate().map_err(|e| Error::Ca(format!("keygen: {e}")))?;
    let mut params = CertificateParams::new(vec![common_name.to_string()])
        .map_err(|e| Error::Ca(format!("csr params: {e}")))?;
    params.distinguished_name.push(DnType::CommonName, common_name);
    let csr = params.serialize_request(&key).map_err(|e| Error::Ca(format!("csr build: {e}")))?;
    Ok((csr.der().to_vec(), key.serialize_pem()))
}

/// `POST /.well-known/est/simpleenroll` using HTTP Basic auth. Returns
/// the freshly-issued leaf cert (DER) on success.
///
/// The returned cert is validated via
/// [`crate::cms::decode_leaf_from_degenerate`] so a server response
/// whose first cert is actually a CA is rejected.
pub async fn simpleenroll(
    http: &reqwest::Client,
    base_url: &str,
    user: &str,
    pass: &str,
    csr_der: &[u8],
) -> Result<Vec<u8>> {
    let url = format!("{}{}/simpleenroll", base_url.trim_end_matches('/'), WELL_KNOWN_PREFIX);
    let body = STANDARD.encode(csr_der);
    let resp = http
        .post(url)
        .basic_auth(user, Some(pass))
        .header(reqwest::header::CONTENT_TYPE, CONTENT_TYPE_PKCS10)
        .header("content-transfer-encoding", "base64")
        .body(body)
        .send()
        .await?;
    unpack_pkcs7(resp, /*require_leaf=*/ true).await
}

/// `POST /.well-known/est/simplereenroll` — renewal against a server
/// enforcing mTLS. The caller is responsible for constructing `http`
/// with the old cert as the client identity.
pub async fn simplereenroll(
    http: &reqwest::Client,
    base_url: &str,
    csr_der: &[u8],
) -> Result<Vec<u8>> {
    let url = format!("{}{}/simplereenroll", base_url.trim_end_matches('/'), WELL_KNOWN_PREFIX);
    let body = STANDARD.encode(csr_der);
    let resp = http
        .post(url)
        .header(reqwest::header::CONTENT_TYPE, CONTENT_TYPE_PKCS10)
        .header("content-transfer-encoding", "base64")
        .body(body)
        .send()
        .await?;
    unpack_pkcs7(resp, /*require_leaf=*/ true).await
}

/// `GET /.well-known/est/cacerts` — fetch the CA bundle. Returns the
/// first cert from the response's PKCS#7 payload (the CA cert). The
/// CA check is deliberately skipped here because this endpoint returns
/// a CA by design.
pub async fn cacerts(http: &reqwest::Client, base_url: &str) -> Result<Vec<u8>> {
    let url = format!("{}{}/cacerts", base_url.trim_end_matches('/'), WELL_KNOWN_PREFIX);
    let resp = http.get(url).send().await?;
    unpack_pkcs7(resp, /*require_leaf=*/ false).await
}

/// Strip base64 transport encoding and unwrap the PKCS#7 degenerate
/// certs-only body. If `require_leaf` is set, additionally refuses the
/// response if the first certificate is a CA.
async fn unpack_pkcs7(resp: reqwest::Response, require_leaf: bool) -> Result<Vec<u8>> {
    let status = resp.status();
    let body = resp.text().await?;
    if !status.is_success() {
        return Err(Error::Auth(format!("EST server returned {status}: {body}")));
    }
    let der = STANDARD
        .decode(body.trim().as_bytes())
        .map_err(|e| Error::Cms(format!("base64 decode EST response: {e}")))?;
    if require_leaf {
        decode_leaf_from_degenerate(&der)
    } else {
        decode_degenerate_first(&der)
    }
}