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