est-ca 0.2.0

RFC 7030 Enrollment over Secure Transport (EST) — client, server, and an internal X.509 CA in pure Rust.
//! PKCS#7 / CMS helpers for EST's on-the-wire encoding.
//!
//! EST returns issued certs as a **CMS `SignedData` with no signers, no
//! content, and the cert chain in the `certificates` set** — what the
//! standard calls a "degenerate certs-only" message (RFC 5652 §5.1).
//! The body is then base64-encoded (per RFC 7030 §4.1.3) for HTTP transport.

use ::cms::cert::CertificateChoices;
use ::cms::content_info::{CmsVersion, ContentInfo};
use ::cms::signed_data::{
    CertificateSet, EncapsulatedContentInfo, SignedData, SignerInfos,
};
use const_oid::db::rfc5911::{ID_DATA, ID_SIGNED_DATA};
use der::asn1::SetOfVec;
use der::{Decode, Encode};
use x509_cert::Certificate;

use crate::error::{Error, Result};

/// Encode one or more certificates as a PKCS#7 degenerate certs-only
/// CMS `SignedData` blob (DER bytes).
///
/// The output matches what RFC 7030 §4.1.3 (`simpleenroll` response) and
/// §4.1.1 (`cacerts` response) require before base64 transport encoding.
pub fn encode_degenerate(certs_der: &[Vec<u8>]) -> Result<Vec<u8>> {
    if certs_der.is_empty() {
        return Err(Error::Cms("encode_degenerate: empty cert list".into()));
    }

    let mut choices: Vec<CertificateChoices> = Vec::with_capacity(certs_der.len());
    for bytes in certs_der {
        let cert = Certificate::from_der(bytes)
            .map_err(|e| Error::Cms(format!("parse cert before wrap: {e}")))?;
        choices.push(CertificateChoices::Certificate(cert));
    }
    let set = SetOfVec::try_from(choices)
        .map_err(|e| Error::Cms(format!("build certificate set: {e}")))?;

    let signed = SignedData {
        version: CmsVersion::V1,
        digest_algorithms: Default::default(),
        encap_content_info: EncapsulatedContentInfo {
            econtent_type: ID_DATA,
            econtent: None,
        },
        certificates: Some(CertificateSet(set)),
        crls: None,
        signer_infos: SignerInfos(Default::default()),
    };

    let content = der::Any::encode_from(&signed)
        .map_err(|e| Error::Cms(format!("encode SignedData: {e}")))?;
    let info = ContentInfo { content_type: ID_SIGNED_DATA, content };
    info.to_der().map_err(|e| Error::Cms(format!("encode ContentInfo: {e}")))
}

/// Extract the first certificate (DER bytes) from a PKCS#7 degenerate
/// certs-only CMS. No semantic check on the cert — use this for
/// `/cacerts` responses where the first cert is expected to be a CA.
pub fn decode_degenerate_first(body_der: &[u8]) -> Result<Vec<u8>> {
    let info = ContentInfo::from_der(body_der)
        .map_err(|e| Error::Cms(format!("parse ContentInfo: {e}")))?;
    if info.content_type != ID_SIGNED_DATA {
        return Err(Error::Cms(format!(
            "expected SignedData OID, got {}",
            info.content_type
        )));
    }
    let signed: SignedData = info
        .content
        .decode_as()
        .map_err(|e| Error::Cms(format!("decode SignedData: {e}")))?;
    let set = signed
        .certificates
        .ok_or_else(|| Error::Cms("SignedData has no certificates".into()))?;
    let first = set.0.iter().next().ok_or_else(|| Error::Cms("empty certificate set".into()))?;
    match first {
        CertificateChoices::Certificate(c) => c
            .to_der()
            .map_err(|e| Error::Cms(format!("re-encode first cert: {e}"))),
        _ => Err(Error::Cms("first certificate choice is not a plain X.509 cert".into())),
    }
}

/// Like [`decode_degenerate_first`] but additionally rejects a first
/// certificate that asserts `BasicConstraints: CA:TRUE`.
///
/// Use this for `/simpleenroll` and `/simplereenroll` responses: a
/// well-behaved server places the issued leaf first (RFC 7030 §4.2.3),
/// and this guard ensures a malicious or buggy server can't coerce the
/// client into trusting a CA certificate as its own leaf.
pub fn decode_leaf_from_degenerate(body_der: &[u8]) -> Result<Vec<u8>> {
    let der = decode_degenerate_first(body_der)?;
    let cert = Certificate::from_der(&der)
        .map_err(|e| Error::Cms(format!("parse decoded leaf: {e}")))?;
    if is_ca_certificate(&cert) {
        return Err(Error::Cms(
            "first certificate in the bundle is a CA (BasicConstraints CA:TRUE) — \
             refusing to treat it as a leaf"
                .into(),
        ));
    }
    Ok(der)
}

/// Return `true` if `cert` carries `basicConstraints` with `cA` set.
/// Defense-in-depth check used by [`decode_leaf_from_degenerate`].
fn is_ca_certificate(cert: &Certificate) -> bool {
    let Some(exts) = cert.tbs_certificate.extensions.as_ref() else {
        return false;
    };
    for ext in exts {
        if ext.extn_id == const_oid::db::rfc5280::ID_CE_BASIC_CONSTRAINTS {
            if let Ok(bc) =
                <x509_cert::ext::pkix::BasicConstraints as Decode>::from_der(ext.extn_value.as_bytes())
            {
                return bc.ca;
            }
        }
    }
    false
}

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

    #[cfg(feature = "ca")]
    #[test]
    fn roundtrip_degenerate_single_cert() {
        // Generate a cheap self-signed cert via rcgen to round-trip.
        let params = rcgen::CertificateParams::new(vec!["test-roundtrip".into()]).unwrap();
        let key = rcgen::KeyPair::generate().unwrap();
        let cert = params.self_signed(&key).unwrap();
        let der = cert.der().to_vec();

        let wrapped = encode_degenerate(&[der.clone()]).unwrap();
        let unwrapped = decode_degenerate_first(&wrapped).unwrap();
        assert_eq!(unwrapped, der, "first cert should round-trip byte-for-byte");
    }
}