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