est-ca 0.1.0

RFC 7030 Enrollment over Secure Transport (EST) — client, server, and an internal X.509 CA in pure Rust.
//! RFC 7030 EST server handlers built on [`axum`].
//!
//! The server is deliberately minimal: it serves the three required EST
//! endpoints (`/cacerts`, `/simpleenroll`, `/simplereenroll`) and
//! delegates all policy decisions to an [`AuthBackend`] supplied by the
//! consumer. Transport-level TLS (and optional client-auth for the
//! renewal endpoint) is the consumer's responsibility — wire it up in
//! the binary that embeds this router.

use std::sync::Arc;

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

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

/// Shared server state passed to every EST handler.
#[derive(Clone)]
pub struct EstServer {
    issuer: Issuer,
    auth: Arc<dyn AuthBackend>,
}

impl EstServer {
    /// Construct a server from an [`Issuer`] and an [`AuthBackend`].
    pub fn new(issuer: Issuer, auth: Arc<dyn AuthBackend>) -> Self {
        Self { issuer, auth }
    }

    /// Build the axum [`Router`] serving the EST endpoints under
    /// [`WELL_KNOWN_PREFIX`].
    pub fn router(self) -> Router {
        Router::new()
            .route(&format!("{WELL_KNOWN_PREFIX}/cacerts"), get(cacerts))
            .route(&format!("{WELL_KNOWN_PREFIX}/simpleenroll"), post(simpleenroll))
            .route(&format!("{WELL_KNOWN_PREFIX}/simplereenroll"), post(simplereenroll))
            .with_state(self)
    }
}

/// `GET /cacerts` — return the CA chain as a base64-wrapped PKCS#7.
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.
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 the TLS client
/// cert; consumers must put an mTLS-enforcing layer in front of this
/// route. An injected `x-est-principal` header (set by the TLS layer
/// after verifying the client cert's CN) carries the verified identity.
async fn simplereenroll(
    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 headers.get("x-est-principal").and_then(|v| v.to_str().ok()) {
        Some(cn) if !cn.is_empty() => Principal::new(cn),
        _ => return (StatusCode::UNAUTHORIZED, "missing verified client cert").into_response(),
    };
    issue_and_respond(&s, &principal, &body).await
}

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

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

async fn issue_and_respond(
    s: &EstServer,
    principal: &Principal,
    body: &[u8],
) -> axum::response::Response {
    // EST (RFC 7030 §3.2.4) accepts the CSR as base64 text *or* raw DER.
    // Try base64 first, fall back to DER.
    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(),
    }
}

/// Build the 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 and §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()
}

/// Content type the client must send for `simpleenroll` bodies (exposed
/// so custom routers can add a strict validator layer).
pub const ACCEPTED_REQUEST_CONTENT_TYPE: &str = CONTENT_TYPE_PKCS10;