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 _;