Skip to main content

kipuka/routes/
est.rs

1//! EST operation router combining all RFC 7030 endpoints.
2//!
3//! Builds the sub-router for EST operations with:
4//!
5//! - Content-Type enforcement middleware (reject wrong content types per
6//!   RFC 7030 §4)
7//! - Base64 transfer encoding enforcement per RFC 8951
8//! - Error response formatting per RFC 7030 §4.2.3
9
10use std::sync::Arc;
11
12use axum::Router;
13use axum::body::Body;
14use axum::http::{HeaderValue, Method, Request, StatusCode, header};
15use axum::middleware::{self, Next};
16use axum::response::{IntoResponse, Response};
17use axum::routing::{get, post};
18
19use crate::state::AppState;
20
21use super::fullcmc;
22use super::{cacerts, csrattrs, serverkeygen, simpleenroll, simplereenroll};
23
24/// Build the EST sub-router with all RFC 7030 operation endpoints.
25///
26/// Each endpoint enforces its own authentication policy via the
27/// [`crate::auth::EstAuth`] or [`crate::auth::OptionalAuth`] extractor.
28///
29/// Content-type enforcement is applied as middleware to all POST routes.
30pub fn est_router() -> Router<Arc<AppState>> {
31    Router::new()
32        .nest("/cms",super::cms_est::cms_est_router())
33        // RFC 7030 §4.1: Distribution of CA Certificates
34        .route("/cacerts", get(cacerts::get_cacerts))
35        // RFC 7030 §4.2: Enrollment (initial)
36        .route(
37            "/simpleenroll",
38            post(simpleenroll::post_simpleenroll),
39        )
40        // RFC 7030 §4.2.2: Re-enrollment
41        .route(
42            "/simplereenroll",
43            post(simplereenroll::post_simplereenroll),
44        )
45        // RFC 7030 §4.3: Full CMC (requires fullcmc feature)
46        // TODO: re-enable after synta-cmc API migration
47        .route("/fullcmc", post(fullcmc::post_fullcmc))
48        // RFC 7030 §4.4: Server-Side Key Generation
49        .route(
50            "/serverkeygen",
51            post(serverkeygen::post_serverkeygen),
52        )
53        // RFC 7030 §4.5: CSR Attributes
54        .route("/csrattrs", get(csrattrs::get_csrattrs))
55        // Content-Type enforcement on POST routes.
56        .layer(middleware::from_fn(enforce_est_content_type))
57}
58
59/// EST content types defined in RFC 7030 §4.
60pub mod content_types {
61    /// PKCS#10 CSR: used by `/simpleenroll`, `/simplereenroll`, `/serverkeygen`.
62    pub const PKCS10: &str = "application/pkcs10";
63
64    /// PKCS#7 certs-only: returned by `/cacerts`, `/simpleenroll`, `/simplereenroll`.
65    pub const PKCS7_CERTS: &str = "application/pkcs7-mime; smime-type=certs-only";
66
67    /// PKCS#7 CMC request: used by `/fullcmc`.
68    pub const CMC_REQUEST: &str = "application/pkcs7-mime; smime-type=CMC-request";
69
70    /// PKCS#7 CMC response: returned by `/fullcmc`.
71    pub const CMC_RESPONSE: &str = "application/pkcs7-mime; smime-type=CMC-response";
72
73    /// CSR attributes: returned by `/csrattrs`.
74    pub const CSR_ATTRS: &str = "application/csrattrs";
75
76    /// PKCS#8 private key: returned as part of `/serverkeygen`.
77    pub const PKCS8: &str = "application/pkcs8";
78
79    /// Multipart/mixed: returned by `/serverkeygen` (cert + private key).
80    pub const MULTIPART_MIXED: &str = "multipart/mixed";
81
82    /// Transfer encoding for EST payloads per RFC 7030 §4.1.
83    pub const TRANSFER_ENCODING_BASE64: &str = "base64";
84}
85
86/// Middleware that enforces Content-Type requirements for EST POST requests.
87///
88/// RFC 7030 §4 defines specific content types for each EST operation:
89///
90/// | Endpoint         | Expected Content-Type                                |
91/// |------------------|------------------------------------------------------|
92/// | /simpleenroll    | application/pkcs10                                   |
93/// | /simplereenroll  | application/pkcs10                                   |
94/// | /serverkeygen    | application/pkcs10                                   |
95/// | /fullcmc         | application/pkcs7-mime; smime-type=CMC-request        |
96///
97/// GET requests are passed through without Content-Type validation.
98async fn enforce_est_content_type(req: Request<Body>, next: Next) -> Response {
99    // Only enforce on POST/PUT methods.
100    if req.method() != Method::POST && req.method() != Method::PUT {
101        return next.run(req).await;
102    }
103
104    let path = req.uri().path().to_string();
105    let content_type = req
106        .headers()
107        .get(header::CONTENT_TYPE)
108        .and_then(|v| v.to_str().ok())
109        .unwrap_or("");
110
111    // Determine the expected content type based on the path.
112    let expected = if path.contains("/cms/") {
113        Some("application/pkcs7-mime")
114    } else if path.ends_with("/simpleenroll")
115        || path.ends_with("/simplereenroll")
116        || path.ends_with("/serverkeygen")
117    {
118        Some(content_types::PKCS10)
119    } else if path.ends_with("/fullcmc") {
120        // CMC requests: accept the full MIME type or just the base type.
121        Some("application/pkcs7-mime")
122    } else {
123        None
124    };
125
126    if let Some(expected_prefix) = expected
127        && !content_type.starts_with(expected_prefix)
128    {
129        tracing::debug!(
130            path = %path,
131            content_type = %content_type,
132            expected = %expected_prefix,
133            "rejecting request with wrong Content-Type"
134        );
135        return (
136            StatusCode::UNSUPPORTED_MEDIA_TYPE,
137            format!("Content-Type must be {expected_prefix}"),
138        )
139            .into_response();
140    }
141
142    next.run(req).await
143}
144
145/// Decode a base64-encoded EST request body.
146///
147/// RFC 7030 §4.1 and RFC 8951 specify that EST request and response
148/// bodies use base64 encoding of the DER-encoded ASN.1 structures.
149///
150/// This function handles:
151/// - Standard base64 (RFC 4648 §4)
152/// - Base64 with line breaks (PEM-style)
153/// - Stripping of whitespace
154pub fn decode_est_base64(body: &[u8]) -> Result<Vec<u8>, String> {
155    // Strip whitespace and line breaks per RFC 8951.
156    let cleaned: Vec<u8> = body
157        .iter()
158        .filter(|b| !b.is_ascii_whitespace())
159        .copied()
160        .collect();
161
162    base64::engine::general_purpose::STANDARD
163        .decode(&cleaned)
164        .map_err(|e| format!("invalid base64 encoding: {e}"))
165}
166
167/// Encode DER bytes as base64 for an EST response body.
168///
169/// Produces standard base64 (RFC 4648 §4) with 76-character line wrapping
170/// per RFC 8951 §3.
171pub fn encode_est_base64(der: &[u8]) -> String {
172    use base64::Engine as _;
173    let encoded = base64::engine::general_purpose::STANDARD.encode(der);
174
175    // RFC 8951 §3: base64-encoded data SHOULD be line-wrapped at 76 chars.
176    let mut wrapped = String::with_capacity(encoded.len() + encoded.len() / 76);
177    for (i, ch) in encoded.chars().enumerate() {
178        if i > 0 && i % 76 == 0 {
179            wrapped.push('\r');
180            wrapped.push('\n');
181        }
182        wrapped.push(ch);
183    }
184    wrapped
185}
186
187/// Build an EST error response per RFC 7030 §4.2.3.
188///
189/// EST error responses use HTTP status codes as the primary error indicator.
190/// For enrollment failures, the server MAY return a CMC Full PKI Response
191/// body with detailed error information.
192///
193/// For now, this returns a `text/plain` body with the error detail.  A
194/// future enhancement will return a proper CMC error body for 4xx responses
195/// on enrollment endpoints.
196pub fn est_error_response(status: StatusCode, detail: &str) -> Response {
197    let mut resp = (status, detail.to_string()).into_response();
198    resp.headers_mut().insert(
199        header::CONTENT_TYPE,
200        HeaderValue::from_static("text/plain; charset=utf-8"),
201    );
202
203    // RFC 7030 §4.2.3: 401 responses include WWW-Authenticate.
204    if status == StatusCode::UNAUTHORIZED {
205        resp.headers_mut().insert(
206            header::WWW_AUTHENTICATE,
207            HeaderValue::from_static("Basic realm=\"EST\""),
208        );
209    }
210
211    // RFC 7030 §4.2.3: 503 responses include Retry-After.
212    if status == StatusCode::SERVICE_UNAVAILABLE
213        && let Ok(hv) = HeaderValue::from_str("120")
214    {
215        resp.headers_mut().insert(header::RETRY_AFTER, hv);
216    }
217
218    resp
219}
220
221use base64::Engine as _;