Skip to main content

kipuka/routes/
star.rs

1//! STAR (Short-Term Automatic Renewal) endpoints (RFC 8739).
2//!
3//! Extends the EST endpoint set (RFC 7030) with STAR semantics for
4//! automatic certificate renewal.  Clients create a STAR order once,
5//! then fetch the latest certificate at any time without re-authenticating.
6//!
7//! # Route structure
8//!
9//! ```text
10//! /.well-known/est/star
11//!     POST              Create STAR order (authenticated)
12//!
13//! /.well-known/est/star/{order_id}
14//!     GET               Fetch current certificate (unauthenticated)
15//!     DELETE            Cancel STAR order (authenticated)
16//!
17//! /.well-known/est/star/{order_id}/history
18//!     GET               List all certificates in series (unauthenticated)
19//! ```
20
21use std::sync::Arc;
22
23use axum::Router;
24use axum::body::Bytes;
25use axum::extract::{Path, State};
26use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
27use axum::response::{IntoResponse, Response};
28use axum::routing::{get, post};
29
30use crate::auth::EstAuth;
31use crate::error::KipukaError;
32use crate::routes::LabelExtractor;
33use crate::routes::est::{content_types, decode_est_base64, encode_est_base64};
34use crate::star::{StarCertificate, StarError};
35use crate::state::AppState;
36
37/// Build the STAR sub-router.
38///
39/// Mounts STAR order management endpoints under `/.well-known/est/star/`.
40/// The router is nested into the main application router by
41/// [`crate::routes::build_router`].
42pub fn star_router() -> Router<Arc<AppState>> {
43    Router::new()
44        .route("/", post(post_star_order))
45        .route(
46            "/{order_id}",
47            get(get_star_certificate).delete(delete_star_order),
48        )
49        .route("/{order_id}/history", get(get_star_history))
50}
51
52/// `POST /.well-known/est/star`
53///
54/// Create a new STAR order.  The client submits a PKCS#10 CSR (base64-
55/// encoded, same as `/simpleenroll`) together with optional STAR-specific
56/// headers:
57///
58/// | Header                  | Type   | Default                          |
59/// |-------------------------|--------|----------------------------------|
60/// | `Star-Renewal-Interval` | u64 s  | `[star].default_renewal_interval_secs` |
61/// | `Star-Lifetime`         | u32 d  | `[star].max_lifetime_days`       |
62///
63/// On success the server issues the first certificate, stores the order,
64/// and returns **201 Created** with a `Star-Order-ID` header.
65///
66/// # Authentication
67///
68/// Requires EST authentication (mTLS or OTP).
69///
70/// # Request
71///
72/// | Header         | Value                |
73/// |----------------|----------------------|
74/// | Content-Type   | `application/pkcs10` |
75/// | Body           | Base64-encoded DER PKCS#10 CSR |
76///
77/// # Response
78///
79/// | Header           | Value                                          |
80/// |------------------|------------------------------------------------|
81/// | Status           | `201 Created`                                  |
82/// | Content-Type     | `application/pkcs7-mime; smime-type=certs-only` |
83/// | Star-Order-ID    | UUID of the created order                      |
84pub async fn post_star_order(
85    auth: EstAuth,
86    label: LabelExtractor,
87    State(state): State<Arc<AppState>>,
88    headers: HeaderMap,
89    body: Bytes,
90) -> Result<Response, KipukaError> {
91    // Check that STAR is enabled.
92    let star_config = state
93        .config
94        .star
95        .as_ref()
96        .filter(|c| c.enabled)
97        .ok_or(KipukaError::NotFound)?;
98
99    // Obtain the STAR manager.
100    let star_manager = state
101        .star_manager
102        .as_ref()
103        .ok_or(KipukaError::ServiceUnavailable(
104            "STAR manager not available".into(),
105        ))?;
106
107    let ca_id = label.ca_id();
108    let identity = &auth.0.identity;
109
110    tracing::info!(
111        ca_id = %ca_id,
112        label = %label.label,
113        identity = %identity,
114        method = ?auth.0.method,
115        "STAR order request"
116    );
117
118    // Parse optional STAR headers, falling back to config defaults.
119    let renewal_interval_secs: u64 = headers
120        .get("star-renewal-interval")
121        .and_then(|v| v.to_str().ok())
122        .and_then(|v| v.parse().ok())
123        .unwrap_or(star_config.default_renewal_interval_secs);
124
125    let lifetime_days: u32 = headers
126        .get("star-lifetime")
127        .and_then(|v| v.to_str().ok())
128        .and_then(|v| v.parse().ok())
129        .unwrap_or(star_config.max_lifetime_days);
130
131    // Clamp to configured bounds.
132    let renewal_interval_secs = renewal_interval_secs
133        .max(star_config.min_renewal_interval_secs)
134        .min(star_config.max_renewal_interval_secs);
135    let lifetime_days = lifetime_days.min(star_config.max_lifetime_days);
136
137    // Decode the base64-encoded CSR.
138    let csr_der = decode_est_base64(&body)
139        .map_err(|e| KipukaError::BadRequest(format!("CSR decoding failed: {e}")))?;
140
141    if csr_der.len() < 60 {
142        return Err(KipukaError::BadRequest(
143            "CSR is too short to be valid".into(),
144        ));
145    }
146
147    // Create the STAR order via the manager.
148    let order = star_manager
149        .create_order(
150            identity.clone(),
151            String::new(), // key_type — extracted from CSR in production
152            "default".to_owned(),
153            renewal_interval_secs,
154            lifetime_days,
155            ca_id.to_owned(),
156            csr_der.clone(),
157            Some(identity.clone()),
158        )
159        .map_err(star_error_to_kipuka)?;
160
161    let order_id = order.id.clone();
162
163    // Issue the first certificate.
164    //
165    // TODO: Implement actual certificate issuance via
166    //       `crate::ca::issue::issue_certificate`.
167    //
168    // let profile = crate::ca::issue::EnrollmentProfile::default();
169    // let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
170    // let result = crate::ca::issue::issue_certificate(&csr_der, &profile, &ca.cert_der)?;
171    let cert_der: Vec<u8> = Vec::new(); // Placeholder
172
173    if cert_der.is_empty() {
174        return Err(KipukaError::Ca(
175            "STAR certificate issuance not yet implemented".into(),
176        ));
177    }
178
179    // Store the first certificate in the order.
180    let first_cert = StarCertificate {
181        certificate_der: cert_der.clone(),
182        serial_number: String::new(), // Populated by actual issuance
183        not_before: chrono::Utc::now(),
184        not_after: chrono::Utc::now() + chrono::Duration::seconds(renewal_interval_secs as i64),
185        renewal_number: 0,
186        star_order_id: order_id.clone(),
187    };
188    star_manager
189        .store_renewed_certificate(&order_id, first_cert)
190        .map_err(star_error_to_kipuka)?;
191
192    // Persist order to database.
193    sqlx::query(
194        "INSERT INTO star_orders \
195         (id, subject_dn, key_type, profile, renewal_interval_secs, \
196          lifetime_end, max_renewals, status, requestor_dn, ca_id, csr_der) \
197         VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)",
198    )
199    .bind(&order_id)
200    .bind(&order.subject_dn)
201    .bind(&order.key_type)
202    .bind(&order.profile)
203    .bind(renewal_interval_secs as i64)
204    .bind(order.lifetime_end.to_rfc3339())
205    .bind(order.max_renewals as i64)
206    .bind(identity)
207    .bind(ca_id)
208    .bind(&csr_der)
209    .execute(&state.db)
210    .await?;
211
212    state
213        .record_audit_event(
214            "star_order_created",
215            &format!("order_id={order_id}, ca_id={ca_id}, identity={identity}"),
216        )
217        .await;
218
219    // Build the response: 201 Created with Star-Order-ID header.
220    let pkcs7_der = cert_der; // Placeholder — wrap in PKCS#7 certs-only
221    let response_body = encode_est_base64(&pkcs7_der);
222
223    let mut resp = (StatusCode::CREATED, response_body).into_response();
224    resp.headers_mut().insert(
225        header::CONTENT_TYPE,
226        HeaderValue::from_static(content_types::PKCS7_CERTS),
227    );
228    resp.headers_mut().insert(
229        header::HeaderName::from_static("content-transfer-encoding"),
230        HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
231    );
232    resp.headers_mut().insert(
233        header::HeaderName::from_static("star-order-id"),
234        HeaderValue::from_str(&order_id).unwrap_or_else(|_| HeaderValue::from_static("unknown")),
235    );
236
237    Ok(resp)
238}
239
240/// `GET /.well-known/est/star/{order_id}`
241///
242/// Fetch the current (most recent) certificate for a STAR order.
243///
244/// No authentication required — STAR certificates are designed to be
245/// fetched by any party that knows the order ID (RFC 8739 §3.4).
246///
247/// # Response
248///
249/// | Status | Meaning                                |
250/// |--------|----------------------------------------|
251/// | 200    | Current certificate returned            |
252/// | 404    | Order not found                        |
253/// | 410    | Order cancelled or expired (Gone)      |
254pub async fn get_star_certificate(
255    Path(order_id): Path<String>,
256    State(state): State<Arc<AppState>>,
257) -> Result<Response, KipukaError> {
258    // Check that STAR is enabled.
259    let _star_config = state
260        .config
261        .star
262        .as_ref()
263        .filter(|c| c.enabled)
264        .ok_or(KipukaError::NotFound)?;
265
266    let star_manager = state
267        .star_manager
268        .as_ref()
269        .ok_or(KipukaError::ServiceUnavailable(
270            "STAR manager not available".into(),
271        ))?;
272
273    // Fetch the current certificate (handles status checks internally).
274    match star_manager.get_current_certificate(&order_id) {
275        Ok(cert) => {
276            let response_body = encode_est_base64(&cert.certificate_der);
277
278            let mut resp = (StatusCode::OK, response_body).into_response();
279            resp.headers_mut().insert(
280                header::CONTENT_TYPE,
281                HeaderValue::from_static(content_types::PKCS7_CERTS),
282            );
283            resp.headers_mut().insert(
284                header::HeaderName::from_static("content-transfer-encoding"),
285                HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
286            );
287            Ok(resp)
288        }
289        Err(StarError::OrderCancelled(_) | StarError::OrderExpired(_)) => {
290            // RFC 8739 §3.4: return 410 Gone for terminated orders.
291            Ok(StatusCode::GONE.into_response())
292        }
293        Err(StarError::OrderNotFound(_)) => Err(KipukaError::NotFound),
294        Err(e) => Err(KipukaError::Internal(e.to_string())),
295    }
296}
297
298/// `DELETE /.well-known/est/star/{order_id}`
299///
300/// Cancel a STAR order.  Future renewals are suppressed and the order
301/// status is set to `cancelled`.  Existing certificates remain valid
302/// until their natural expiry.
303///
304/// # Authentication
305///
306/// Requires EST authentication (mTLS or OTP).
307///
308/// # Response
309///
310/// | Status | Meaning                        |
311/// |--------|--------------------------------|
312/// | 204    | Order cancelled successfully   |
313/// | 404    | Order not found                |
314pub async fn delete_star_order(
315    auth: EstAuth,
316    Path(order_id): Path<String>,
317    State(state): State<Arc<AppState>>,
318) -> Result<Response, KipukaError> {
319    // Check that STAR is enabled.
320    let _star_config = state
321        .config
322        .star
323        .as_ref()
324        .filter(|c| c.enabled)
325        .ok_or(KipukaError::NotFound)?;
326
327    let star_manager = state
328        .star_manager
329        .as_ref()
330        .ok_or(KipukaError::ServiceUnavailable(
331            "STAR manager not available".into(),
332        ))?;
333
334    let identity = &auth.0.identity;
335
336    tracing::info!(
337        order_id = %order_id,
338        identity = %identity,
339        "STAR order cancellation request"
340    );
341
342    // Cancel via the STAR manager.
343    star_manager
344        .cancel_order(&order_id)
345        .map_err(star_error_to_kipuka)?;
346
347    // Update database.
348    sqlx::query("UPDATE star_orders SET status = 'cancelled', cancelled_at = ? WHERE id = ?")
349        .bind(chrono::Utc::now().to_rfc3339())
350        .bind(&order_id)
351        .execute(&state.db)
352        .await?;
353
354    state
355        .record_audit_event(
356            "star_order_cancelled",
357            &format!("order_id={order_id}, identity={identity}"),
358        )
359        .await;
360
361    Ok(StatusCode::NO_CONTENT.into_response())
362}
363
364/// `GET /.well-known/est/star/{order_id}/history`
365///
366/// List all certificates issued in the STAR renewal series, ordered by
367/// renewal number.  Returns a JSON array suitable for monitoring and
368/// auditing STAR certificate rotation.
369///
370/// # Response
371///
372/// | Header       | Value              |
373/// |--------------|--------------------|
374/// | Content-Type | `application/json` |
375///
376/// ```json
377/// [
378///   {
379///     "serial": "01AB...",
380///     "not_before": "2025-06-01T00:00:00Z",
381///     "not_after": "2025-06-02T00:00:00Z",
382///     "renewal_number": 0
383///   }
384/// ]
385/// ```
386pub async fn get_star_history(
387    Path(order_id): Path<String>,
388    State(state): State<Arc<AppState>>,
389) -> Result<Response, KipukaError> {
390    // Check that STAR is enabled.
391    let _star_config = state
392        .config
393        .star
394        .as_ref()
395        .filter(|c| c.enabled)
396        .ok_or(KipukaError::NotFound)?;
397
398    // Verify the order exists (check in-memory first, fall back to DB).
399    let star_manager = state.star_manager.as_ref();
400    let in_memory = star_manager.and_then(|m| m.get_order(&order_id)).is_some();
401
402    if !in_memory {
403        // Check DB as well — the order may have been cleaned from memory.
404        let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM star_orders WHERE id = ?")
405            .bind(&order_id)
406            .fetch_one(&state.db)
407            .await
408            .unwrap_or((0,));
409
410        if count.0 == 0 {
411            return Err(KipukaError::NotFound);
412        }
413    }
414
415    // Query all certificates in the series.
416    let rows: Vec<StarCertRow> = sqlx::query_as(
417        "SELECT serial_number, not_before, not_after, renewal_number \
418         FROM star_certificates WHERE star_order_id = ? ORDER BY renewal_number ASC",
419    )
420    .bind(&order_id)
421    .fetch_all(&state.db)
422    .await?;
423
424    let entries: Vec<serde_json::Value> = rows
425        .iter()
426        .map(|r| {
427            serde_json::json!({
428                "serial": r.serial_number,
429                "not_before": r.not_before,
430                "not_after": r.not_after,
431                "renewal_number": r.renewal_number,
432            })
433        })
434        .collect();
435
436    let json_body = serde_json::to_string(&entries)
437        .map_err(|e| KipukaError::Internal(format!("JSON serialization failed: {e}")))?;
438
439    let mut resp = (StatusCode::OK, json_body).into_response();
440    resp.headers_mut().insert(
441        header::CONTENT_TYPE,
442        HeaderValue::from_static("application/json"),
443    );
444
445    Ok(resp)
446}
447
448/// Row type for STAR certificate history queries.
449#[derive(sqlx::FromRow)]
450struct StarCertRow {
451    serial_number: String,
452    not_before: String,
453    not_after: String,
454    renewal_number: i64,
455}
456
457/// Map a [`StarError`] to a [`KipukaError`] for HTTP response generation.
458fn star_error_to_kipuka(e: StarError) -> KipukaError {
459    match e {
460        StarError::OrderNotFound(_) => KipukaError::NotFound,
461        StarError::OrderCancelled(id) => {
462            KipukaError::BadRequest(format!("STAR order {id} is cancelled"))
463        }
464        StarError::OrderExpired(id) => {
465            KipukaError::BadRequest(format!("STAR order {id} has expired"))
466        }
467        StarError::MaxRenewalsReached { order_id, max } => KipukaError::BadRequest(format!(
468            "STAR order {order_id} reached maximum renewals ({max})"
469        )),
470        StarError::MaxOrdersReached { limit } => {
471            KipukaError::ServiceUnavailable(format!("maximum active STAR orders reached ({limit})"))
472        }
473        StarError::InvalidInterval {
474            requested,
475            min,
476            max,
477        } => KipukaError::BadRequest(format!(
478            "renewal interval {requested}s outside allowed range {min}s–{max}s"
479        )),
480        StarError::IssuanceError(msg) => KipukaError::Ca(msg),
481        StarError::DatabaseError(msg) => KipukaError::Db(msg),
482    }
483}