Skip to main content

kipuka/
error.rs

1//! Unified error type for Kipuka EST server.
2//!
3//! `KipukaError` covers all failure modes from configuration through
4//! certificate issuance.  The [`IntoResponse`] impl produces HTTP responses
5//! appropriate for EST clients:
6//!
7//! - Client errors return `4xx` with a `text/plain` body describing the problem.
8//! - Server errors return `500` or `503`; internal details are logged but not
9//!   exposed to the client.
10//! - EST-specific error semantics follow RFC 7030 §4.2.3 (CMC response for
11//!   enrollment failures) and §3.2.4 (HTTP error codes).
12
13use axum::{
14    http::{HeaderValue, StatusCode},
15    response::{IntoResponse, Response},
16};
17
18/// Unified error type for the Kipuka EST server.
19///
20/// Each variant maps to a specific failure domain.  The [`IntoResponse`]
21/// implementation translates these into HTTP responses suitable for EST
22/// clients per RFC 7030 §4.2.3.
23#[derive(Debug, thiserror::Error)]
24pub enum KipukaError {
25    // ── Startup / configuration ──────────────────────────────────────────────
26    /// Configuration file parse or validation error.
27    #[error("configuration error: {0}")]
28    Config(String),
29
30    /// TLS setup failure (certificate loading, cipher negotiation, etc.).
31    #[error("TLS error: {0}")]
32    Tls(String),
33
34    /// Database connection or query failure.
35    #[error("database error: {0}")]
36    Db(String),
37
38    /// HSM / PKCS#11 session error.
39    #[error("HSM error: {0}")]
40    Hsm(String),
41
42    // ── Request-level errors ─────────────────────────────────────────────────
43    /// Authentication or authorization failure.
44    ///
45    /// RFC 7030 §3.2.3: EST server MUST respond with 401 when the client
46    /// fails HTTP-based or certificate-based authentication.
47    #[error("authentication error: {0}")]
48    Auth(String),
49
50    /// EST protocol-level error (bad CSR, unsupported operation, etc.).
51    ///
52    /// RFC 7030 §4.2.3: the server MAY return a CMC response body with
53    /// a Full PKI Response indicating the failure reason.
54    #[error("EST error: {0}")]
55    Est(String),
56
57    /// CA signing or certificate issuance error.
58    #[error("CA error: {0}")]
59    Ca(String),
60
61    /// Audit subsystem failure.
62    ///
63    /// NIAP CA PP FAU_STG.4: when the audit trail is full and the overflow
64    /// policy is `halt`, EST operations MUST be rejected.
65    #[error("audit error: {0}")]
66    Audit(String),
67
68    /// I/O error (file system, socket, etc.).
69    #[error("I/O error: {0}")]
70    Io(String),
71
72    // ── HTTP-level errors ────────────────────────────────────────────────────
73    /// Resource not found (unknown EST label, unknown CA, etc.).
74    #[error("not found")]
75    NotFound,
76
77    /// HTTP method not allowed on this endpoint.
78    #[error("method not allowed")]
79    MethodNotAllowed,
80
81    /// Request payload exceeds configured `max_body_size`.
82    #[error("payload too large")]
83    PayloadTooLarge,
84
85    /// Content-Type is not `application/pkcs10` or another expected EST type.
86    ///
87    /// RFC 7030 §4.2: EST endpoints expect specific MIME types.
88    #[error("unsupported media type")]
89    UnsupportedMediaType,
90
91    /// Bad request: malformed CSR, missing fields, etc.
92    #[error("bad request: {0}")]
93    BadRequest(String),
94
95    /// Service temporarily unavailable (HSM offline, DB unreachable, etc.).
96    #[error("service unavailable: {0}")]
97    ServiceUnavailable(String),
98
99    /// Catch-all internal server error.
100    #[error("internal server error: {0}")]
101    Internal(String),
102}
103
104impl From<sqlx::Error> for KipukaError {
105    fn from(e: sqlx::Error) -> Self {
106        KipukaError::Db(e.to_string())
107    }
108}
109
110impl From<std::io::Error> for KipukaError {
111    fn from(e: std::io::Error) -> Self {
112        KipukaError::Io(e.to_string())
113    }
114}
115
116impl KipukaError {
117    /// Map the error variant to an HTTP status code.
118    ///
119    /// EST error responses follow RFC 7030 §4.2.3 and the general HTTP
120    /// status code semantics from RFC 7231.
121    fn http_status(&self) -> StatusCode {
122        match self {
123            // Client errors
124            KipukaError::Auth(_) => StatusCode::UNAUTHORIZED,
125            KipukaError::Est(_) => StatusCode::BAD_REQUEST,
126            KipukaError::BadRequest(_) => StatusCode::BAD_REQUEST,
127            KipukaError::NotFound => StatusCode::NOT_FOUND,
128            KipukaError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
129            KipukaError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
130            KipukaError::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
131            KipukaError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
132
133            // Server errors — never expose internal details to EST clients
134            KipukaError::Config(_)
135            | KipukaError::Tls(_)
136            | KipukaError::Db(_)
137            | KipukaError::Hsm(_)
138            | KipukaError::Ca(_)
139            | KipukaError::Audit(_)
140            | KipukaError::Io(_)
141            | KipukaError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
142        }
143    }
144
145    /// Return a client-safe error detail string.
146    ///
147    /// Server-side errors return a generic message; client errors return
148    /// the specific detail to aid debugging.
149    fn client_detail(&self) -> String {
150        let status = self.http_status();
151        if status.is_server_error() {
152            "internal server error".to_string()
153        } else {
154            self.to_string()
155        }
156    }
157}
158
159impl IntoResponse for KipukaError {
160    fn into_response(self) -> Response {
161        let status = self.http_status();
162
163        // Log server errors at error level; client errors at debug level.
164        if status.is_server_error() {
165            tracing::error!(error = %self, status = status.as_u16(), "server error");
166        } else {
167            tracing::debug!(error = %self, status = status.as_u16(), "client error");
168        }
169
170        let detail = self.client_detail();
171
172        // EST error responses use text/plain per RFC 7030 §4.2.3.
173        // For enrollment failures, a CMC Full PKI Response (application/pkcs7-mime)
174        // could be returned instead — that will be implemented when the EST
175        // enrollment handlers are built.
176        let mut resp = (status, detail).into_response();
177        resp.headers_mut().insert(
178            axum::http::header::CONTENT_TYPE,
179            HeaderValue::from_static("text/plain; charset=utf-8"),
180        );
181
182        // RFC 7030 §4.2.3: 401 responses MUST include WWW-Authenticate.
183        // RFC 7617 §2.2: the challenge includes charset="UTF-8" to indicate
184        // the server accepts UTF-8 encoded credentials.
185        if status == StatusCode::UNAUTHORIZED {
186            resp.headers_mut().insert(
187                axum::http::header::WWW_AUTHENTICATE,
188                HeaderValue::from_static(kipuka_util::WWW_AUTHENTICATE_BASIC),
189            );
190        }
191
192        // RFC 7030 §4.2.3: 503 responses SHOULD include Retry-After.
193        if status == StatusCode::SERVICE_UNAVAILABLE
194            && let Ok(hv) = HeaderValue::from_str("120")
195        {
196            resp.headers_mut()
197                .insert(axum::http::header::RETRY_AFTER, hv);
198        }
199
200        resp
201    }
202}
203
204/// Convenience alias used throughout the server.
205pub type Result<T> = std::result::Result<T, KipukaError>;
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn auth_error_returns_401() {
213        let err = KipukaError::Auth("bad certificate".into());
214        assert_eq!(err.http_status(), StatusCode::UNAUTHORIZED);
215    }
216
217    #[test]
218    fn est_error_returns_400() {
219        let err = KipukaError::Est("malformed CSR".into());
220        assert_eq!(err.http_status(), StatusCode::BAD_REQUEST);
221    }
222
223    #[test]
224    fn db_error_returns_500() {
225        let err = KipukaError::Db("connection refused".into());
226        assert_eq!(err.http_status(), StatusCode::INTERNAL_SERVER_ERROR);
227    }
228
229    #[test]
230    fn server_error_hides_detail() {
231        let err = KipukaError::Internal("secret details".into());
232        assert_eq!(err.client_detail(), "internal server error");
233    }
234
235    #[test]
236    fn client_error_exposes_detail() {
237        let err = KipukaError::BadRequest("missing CN".into());
238        assert_eq!(err.client_detail(), "bad request: missing CN");
239    }
240
241    #[test]
242    fn from_sqlx_error() {
243        let sqlx_err = sqlx::Error::RowNotFound;
244        let err = KipukaError::from(sqlx_err);
245        assert!(matches!(err, KipukaError::Db(_)));
246    }
247
248    #[test]
249    fn from_io_error() {
250        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
251        let err = KipukaError::from(io_err);
252        assert!(matches!(err, KipukaError::Io(_)));
253    }
254
255    #[test]
256    fn into_response_unauthorized_has_www_authenticate() {
257        let resp = KipukaError::Auth("bad cert".into()).into_response();
258        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
259        assert!(resp.headers().get("www-authenticate").is_some());
260    }
261
262    #[test]
263    fn into_response_service_unavailable_has_retry_after() {
264        let resp = KipukaError::ServiceUnavailable("HSM offline".into()).into_response();
265        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
266        assert!(resp.headers().get("retry-after").is_some());
267    }
268
269    #[test]
270    fn into_response_content_type_is_text_plain() {
271        let resp = KipukaError::NotFound.into_response();
272        assert_eq!(
273            resp.headers().get("content-type").unwrap(),
274            "text/plain; charset=utf-8"
275        );
276    }
277}