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    /// Forbidden: the client is authenticated but not authorized.
92    ///
93    /// RFC 7231 §6.5.3: the server understood the request but refuses
94    /// to authorize it.  Used for EKU policy violations, identity
95    /// mismatches, and revoked-certificate enrollment attempts.
96    #[error("forbidden: {0}")]
97    Forbidden(String),
98
99    /// Bad request: malformed CSR, missing fields, etc.
100    #[error("bad request: {0}")]
101    BadRequest(String),
102
103    /// Service temporarily unavailable (HSM offline, DB unreachable, etc.).
104    #[error("service unavailable: {0}")]
105    ServiceUnavailable(String),
106
107    /// Catch-all internal server error.
108    #[error("internal server error: {0}")]
109    Internal(String),
110}
111
112impl From<sqlx::Error> for KipukaError {
113    fn from(e: sqlx::Error) -> Self {
114        KipukaError::Db(e.to_string())
115    }
116}
117
118impl From<std::io::Error> for KipukaError {
119    fn from(e: std::io::Error) -> Self {
120        KipukaError::Io(e.to_string())
121    }
122}
123
124impl From<crate::ca::issue::IssuanceError> for KipukaError {
125    /// Classify an issuance failure into the correct HTTP domain.
126    ///
127    /// A fault in the client-supplied CSR (bad self-signature/PoP, undersized
128    /// key, disallowed algorithm, prohibited DN attribute) is a
129    /// [`BadRequest`](KipukaError::BadRequest) → `400`: the requester can fix
130    /// the CSR and retry, and the specific reason is safe to return.  Every
131    /// other failure — signing, storage, or a server-side profile
132    /// misconfiguration — is a [`Ca`](KipukaError::Ca) error → `500`, whose
133    /// detail is logged but never exposed to the client.
134    ///
135    /// The client/server split is owned by
136    /// [`IssuanceError::is_client_error`](crate::ca::issue::IssuanceError::is_client_error)
137    /// so HTTP and CoAP transports classify identically.
138    fn from(e: crate::ca::issue::IssuanceError) -> Self {
139        if e.is_client_error() {
140            KipukaError::BadRequest(e.to_string())
141        } else {
142            KipukaError::Ca(e.to_string())
143        }
144    }
145}
146
147impl KipukaError {
148    /// Map the error variant to an HTTP status code.
149    ///
150    /// EST error responses follow RFC 7030 §4.2.3 and the general HTTP
151    /// status code semantics from RFC 7231.
152    fn http_status(&self) -> StatusCode {
153        match self {
154            // Client errors
155            KipukaError::Auth(_) => StatusCode::UNAUTHORIZED,
156            KipukaError::Forbidden(_) => StatusCode::FORBIDDEN,
157            KipukaError::Est(_) => StatusCode::BAD_REQUEST,
158            KipukaError::BadRequest(_) => StatusCode::BAD_REQUEST,
159            KipukaError::NotFound => StatusCode::NOT_FOUND,
160            KipukaError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
161            KipukaError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
162            KipukaError::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
163            KipukaError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
164
165            // Server errors — never expose internal details to EST clients
166            KipukaError::Config(_)
167            | KipukaError::Tls(_)
168            | KipukaError::Db(_)
169            | KipukaError::Hsm(_)
170            | KipukaError::Ca(_)
171            | KipukaError::Audit(_)
172            | KipukaError::Io(_)
173            | KipukaError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
174        }
175    }
176
177    /// Return a client-safe error detail string.
178    ///
179    /// Server-side errors return a generic message; client errors return
180    /// the specific detail to aid debugging.
181    fn client_detail(&self) -> String {
182        let status = self.http_status();
183        if status.is_server_error() {
184            "internal server error".to_string()
185        } else {
186            self.to_string()
187        }
188    }
189}
190
191impl IntoResponse for KipukaError {
192    fn into_response(self) -> Response {
193        let status = self.http_status();
194
195        // Log server errors at error level; client errors at debug level.
196        if status.is_server_error() {
197            tracing::error!(error = %self, status = status.as_u16(), "server error");
198        } else {
199            tracing::debug!(error = %self, status = status.as_u16(), "client error");
200        }
201
202        let detail = self.client_detail();
203
204        // EST error responses use text/plain per RFC 7030 §4.2.3.
205        // For enrollment failures, a CMC Full PKI Response (application/pkcs7-mime)
206        // could be returned instead — that will be implemented when the EST
207        // enrollment handlers are built.
208        let mut resp = (status, detail).into_response();
209        resp.headers_mut().insert(
210            axum::http::header::CONTENT_TYPE,
211            HeaderValue::from_static("text/plain; charset=utf-8"),
212        );
213
214        // RFC 7030 §4.2.3: 401 responses MUST include WWW-Authenticate.
215        // RFC 7617 §2.2: the challenge includes charset="UTF-8" to indicate
216        // the server accepts UTF-8 encoded credentials.
217        if status == StatusCode::UNAUTHORIZED {
218            resp.headers_mut().insert(
219                axum::http::header::WWW_AUTHENTICATE,
220                HeaderValue::from_static(kipuka_util::WWW_AUTHENTICATE_BASIC),
221            );
222        }
223
224        // RFC 7030 §4.2.3: 503 responses SHOULD include Retry-After.
225        if status == StatusCode::SERVICE_UNAVAILABLE
226            && let Ok(hv) = HeaderValue::from_str("120")
227        {
228            resp.headers_mut()
229                .insert(axum::http::header::RETRY_AFTER, hv);
230        }
231
232        resp
233    }
234}
235
236/// Convenience alias used throughout the server.
237pub type Result<T> = std::result::Result<T, KipukaError>;
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn auth_error_returns_401() {
245        let err = KipukaError::Auth("bad certificate".into());
246        assert_eq!(err.http_status(), StatusCode::UNAUTHORIZED);
247    }
248
249    #[test]
250    fn est_error_returns_400() {
251        let err = KipukaError::Est("malformed CSR".into());
252        assert_eq!(err.http_status(), StatusCode::BAD_REQUEST);
253    }
254
255    #[test]
256    fn db_error_returns_500() {
257        let err = KipukaError::Db("connection refused".into());
258        assert_eq!(err.http_status(), StatusCode::INTERNAL_SERVER_ERROR);
259    }
260
261    #[test]
262    fn server_error_hides_detail() {
263        let err = KipukaError::Internal("secret details".into());
264        assert_eq!(err.client_detail(), "internal server error");
265    }
266
267    #[test]
268    fn client_error_exposes_detail() {
269        let err = KipukaError::BadRequest("missing CN".into());
270        assert_eq!(err.client_detail(), "bad request: missing CN");
271    }
272
273    #[test]
274    fn from_sqlx_error() {
275        let sqlx_err = sqlx::Error::RowNotFound;
276        let err = KipukaError::from(sqlx_err);
277        assert!(matches!(err, KipukaError::Db(_)));
278    }
279
280    #[test]
281    fn from_io_error() {
282        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
283        let err = KipukaError::from(io_err);
284        assert!(matches!(err, KipukaError::Io(_)));
285    }
286
287    #[test]
288    fn into_response_unauthorized_has_www_authenticate() {
289        let resp = KipukaError::Auth("bad cert".into()).into_response();
290        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
291        assert!(resp.headers().get("www-authenticate").is_some());
292    }
293
294    #[test]
295    fn into_response_service_unavailable_has_retry_after() {
296        let resp = KipukaError::ServiceUnavailable("HSM offline".into()).into_response();
297        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
298        assert!(resp.headers().get("retry-after").is_some());
299    }
300
301    #[test]
302    fn into_response_content_type_is_text_plain() {
303        let resp = KipukaError::NotFound.into_response();
304        assert_eq!(
305            resp.headers().get("content-type").unwrap(),
306            "text/plain; charset=utf-8"
307        );
308    }
309}