est-ca 0.2.0

RFC 7030 Enrollment over Secure Transport (EST) — client, server, and an internal X.509 CA in pure Rust.
//! Route handler implementations + shared helpers.

use axum::extract::State;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::Extension;
use base64::{engine::general_purpose::STANDARD, Engine as _};

use crate::auth::basic::parse_basic;
use crate::auth::Principal;
use crate::cms::encode_degenerate;
use crate::est::{CONTENT_TYPE_PKCS10, CONTENT_TYPE_PKCS7_CERTS_ONLY};

use super::EstServer;

/// `GET /cacerts` — return the CA chain as a base64-wrapped PKCS#7.
pub(super) async fn cacerts(State(s): State<EstServer>) -> impl IntoResponse {
    match encode_degenerate(&[s.issuer.ca_cert_der().to_vec()]) {
        Ok(der) => pkcs7_response(&der),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
    }
}

/// `POST /simpleenroll` — bootstrap enrollment authenticated by HTTP Basic.
pub(super) async fn simpleenroll(
    State(s): State<EstServer>,
    headers: HeaderMap,
    body: axum::body::Bytes,
) -> axum::response::Response {
    if let Err(resp) = require_pkcs10_content_type(&headers) {
        return resp;
    }
    let principal = match authenticate_basic(&s, &headers) {
        Ok(p) => p,
        Err(resp) => return resp,
    };
    issue_and_respond(&s, &principal, &body).await
}

/// `POST /simplereenroll` — renewal. Identity comes from a
/// [`Principal`] request extension that the consumer's mTLS middleware
/// must insert (see module-level docs). A request that arrives without
/// the extension is rejected with 401.
pub(super) async fn simplereenroll(
    State(s): State<EstServer>,
    principal: Option<Extension<Principal>>,
    headers: HeaderMap,
    body: axum::body::Bytes,
) -> axum::response::Response {
    if let Err(resp) = require_pkcs10_content_type(&headers) {
        return resp;
    }
    let Some(Extension(principal)) = principal else {
        return (
            StatusCode::UNAUTHORIZED,
            "missing Principal extension — configure your TLS layer to insert one",
        )
            .into_response();
    };
    issue_and_respond(&s, &principal, &body).await
}

/// `401` unless the request carries `Authorization: Basic …` and the
/// supplied credentials pass the server's [`AuthBackend`].
fn authenticate_basic(
    s: &EstServer,
    headers: &HeaderMap,
) -> Result<Principal, axum::response::Response> {
    let header = headers
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .ok_or_else(|| {
            (
                StatusCode::UNAUTHORIZED,
                [(header::WWW_AUTHENTICATE, "Basic realm=\"est\"")],
                "missing Authorization header",
            )
                .into_response()
        })?;
    let (user, pass) = parse_basic(header).ok_or_else(|| {
        (StatusCode::UNAUTHORIZED, "malformed Basic auth").into_response()
    })?;
    s.auth
        .verify_bootstrap(&user, &pass)
        .map_err(|reason| (StatusCode::FORBIDDEN, reason).into_response())
}

/// Sign the CSR and wrap the issued leaf in a PKCS#7 degenerate bundle.
async fn issue_and_respond(
    s: &EstServer,
    principal: &Principal,
    body: &[u8],
) -> axum::response::Response {
    // EST §3.2.4 specifies base64 transport encoding for the PKCS#10
    // body; we also accept raw DER as a lenient fallback for clients
    // that don't implement the CTE.
    let csr_der = match STANDARD.decode(body) {
        Ok(d) => d,
        Err(_) => body.to_vec(),
    };
    match s.issuer.sign_csr(&csr_der, &principal.id) {
        Ok(leaf_der) => match encode_degenerate(&[leaf_der]) {
            Ok(wrapped) => pkcs7_response(&wrapped),
            Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
        },
        Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
    }
}

/// Return `415 Unsupported Media Type` unless the request declares
/// `Content-Type: application/pkcs10` (RFC 7030 §3.2.4).
fn require_pkcs10_content_type(
    headers: &HeaderMap,
) -> std::result::Result<(), axum::response::Response> {
    let got = headers
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    let base = got.split(';').next().unwrap_or("").trim();
    if base.eq_ignore_ascii_case(CONTENT_TYPE_PKCS10) {
        Ok(())
    } else {
        Err((
            StatusCode::UNSUPPORTED_MEDIA_TYPE,
            format!("expected Content-Type: {CONTENT_TYPE_PKCS10}"),
        )
            .into_response())
    }
}

/// Standard EST PKCS#7 response: base64-encoded body with
/// `Content-Type: application/pkcs7-mime; smime-type=certs-only` and
/// `Content-Transfer-Encoding: base64` (RFC 7030 §4.1.3 + §4.2.3).
fn pkcs7_response(der: &[u8]) -> axum::response::Response {
    let body = STANDARD.encode(der);
    (
        StatusCode::OK,
        [
            (header::CONTENT_TYPE, CONTENT_TYPE_PKCS7_CERTS_ONLY),
            (header::HeaderName::from_static("content-transfer-encoding"), "base64"),
        ],
        body,
    )
        .into_response()
}