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    crate::auth::enroll_authz::authorize_csr_der(&csr_der, identity, &label.enroll_policy())
148        .map_err(KipukaError::Forbidden)?;
149    let ca = state.get_ca(ca_id).ok_or(KipukaError::NotFound)?;
150    let ca_cfg = state
151        .config
152        .cas
153        .iter()
154        .find(|c| c.id == ca_id)
155        .ok_or(KipukaError::NotFound)?;
156    let resolved_key = crate::ca::issue::resolve_signing_key(ca_cfg, state.hsm.as_ref()).await?;
157    let profile = crate::ca::issue::EnrollmentProfile {
158        max_validity_days: label
159            .max_validity_days
160            .unwrap_or(ca.validity_days)
161            .min(crate::ca::issue::cab_forum_max_validity_days()),
162        max_validity_seconds: Some(renewal_interval_secs),
163        max_not_after: Some(chrono::Utc::now() + chrono::Duration::days(i64::from(lifetime_days))),
164        ..Default::default()
165    };
166    crate::audit::record_checked(
167        &state.db,
168        &state.audit,
169        crate::audit::AuditEvent::new(crate::audit::AuditEventType::EnrollRequest)
170            .with_operator(identity)
171            .with_detail("STAR admission"),
172    )
173    .await?;
174    let _operation = star_manager.operation_guard().await;
175    let order = star_manager
176        .create_order(
177            identity.clone(),
178            format!(
179                "{:?}",
180                super::serverkeygen::detect_key_type_from_csr(&csr_der)
181            ),
182            serde_json::to_string(&profile).map_err(|e| KipukaError::Internal(e.to_string()))?,
183            renewal_interval_secs,
184            lifetime_days,
185            ca_id.to_owned(),
186            csr_der.clone(),
187            Some(identity.clone()),
188        )
189        .map_err(star_error_to_kipuka)?;
190    let order_id = order.id.clone();
191    let issued: Result<_, KipukaError> = async {
192        // Issue the first certificate.
193        let result = crate::ca::issue::issue_certificate(
194            &csr_der,
195            &profile,
196            &ca.cert_der,
197            resolved_key.as_signing_key(),
198            &ca.hash_algorithm,
199            ca.ocsp_url.as_deref(),
200            ca.crl_url.as_deref(),
201        )
202        .map_err(KipukaError::from)?;
203
204        // Store the first certificate in the order.
205        let first_cert = StarCertificate {
206            certificate_der: result.certificate_der.clone(),
207            serial_number: result.serial_number.clone(),
208            not_before: result.not_before,
209            not_after: result.not_after,
210            renewal_number: 1,
211            star_order_id: order_id.clone(),
212        };
213        let mut tx = crate::db::begin_write(&state.db, state.db_kind).await?;
214        // Persist order to database.
215        sqlx::query(crate::db::pg_sql(
216            "INSERT INTO star_orders \
217         (id, subject_dn, key_type, profile, renewal_interval_secs, \
218          lifetime_end, max_renewals, current_renewals, status, requestor_dn, ca_id, csr_der) \
219         VALUES (?, ?, ?, ?, ?, ?, ?, 1, 'active', ?, ?, ?)",
220        ))
221        .bind(&order_id)
222        .bind(&order.subject_dn)
223        .bind(&order.key_type)
224        .bind(&order.profile)
225        .bind(renewal_interval_secs as i64)
226        .bind(order.lifetime_end.to_rfc3339())
227        .bind(order.max_renewals as i64)
228        .bind(identity)
229        .bind(ca_id)
230        .bind(&csr_der)
231        .execute(&mut *tx)
232        .await?;
233
234        // Persist the first certificate to the star_certificates table.
235        sqlx::query(crate::db::pg_sql(
236            "INSERT INTO star_certificates \
237         (star_order_id, serial_number, certificate_der, not_before, not_after, renewal_number) \
238         VALUES (?, ?, ?, ?, ?, ?)",
239        ))
240        .bind(&order_id)
241        .bind(&first_cert.serial_number)
242        .bind(&first_cert.certificate_der)
243        .bind(first_cert.not_before.to_rfc3339())
244        .bind(first_cert.not_after.to_rfc3339())
245        .bind(first_cert.renewal_number as i64)
246        .execute(&mut *tx)
247        .await?;
248
249        crate::ca::issue::persist_certificate_on(&mut *tx, ca_id, &profile.name, &result).await?;
250        crate::audit::record_issuance_in_transaction(
251            &mut tx,
252            &state.audit,
253            ca_id,
254            format!(
255                "STAR order={order_id}, identity={identity}, serial={}",
256                result.serial_number
257            ),
258        )
259        .await?;
260        crate::audit::commit_issuance(tx, &state.audit).await?;
261        star_manager
262            .store_renewed_certificate(&order_id, first_cert)
263            .map_err(star_error_to_kipuka)?;
264        Ok(result)
265    }
266    .await;
267    let result = match issued {
268        Ok(result) => result,
269        Err(error) => {
270            star_manager.remove_order(&order_id);
271            return Err(error);
272        }
273    };
274
275    // Wrap the issued certificate in PKCS#7 certs-only (RFC 7030 §4.2.3).
276    let pkcs7_der = crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(
277        &result.certificate_der,
278    ))?;
279    let response_body = encode_est_base64(&pkcs7_der);
280
281    let mut resp = (StatusCode::CREATED, response_body).into_response();
282    resp.headers_mut().insert(
283        header::CONTENT_TYPE,
284        HeaderValue::from_static(content_types::PKCS7_CERTS),
285    );
286    resp.headers_mut().insert(
287        header::HeaderName::from_static("content-transfer-encoding"),
288        HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
289    );
290    resp.headers_mut().insert(
291        header::HeaderName::from_static("star-order-id"),
292        HeaderValue::from_str(&order_id).unwrap_or_else(|_| HeaderValue::from_static("unknown")),
293    );
294
295    Ok(resp)
296}
297
298/// `GET /.well-known/est/star/{order_id}`
299///
300/// Fetch the current (most recent) certificate for a STAR order.
301///
302/// No authentication required — STAR certificates are designed to be
303/// fetched by any party that knows the order ID (RFC 8739 §3.4).
304///
305/// # Response
306///
307/// | Status | Meaning                                |
308/// |--------|----------------------------------------|
309/// | 200    | Current certificate returned            |
310/// | 404    | Order not found                        |
311/// | 410    | Order cancelled or expired (Gone)      |
312pub async fn get_star_certificate(
313    Path(order_id): Path<String>,
314    State(state): State<Arc<AppState>>,
315) -> Result<Response, KipukaError> {
316    // Check that STAR is enabled.
317    let _star_config = state
318        .config
319        .star
320        .as_ref()
321        .filter(|c| c.enabled)
322        .ok_or(KipukaError::NotFound)?;
323
324    let star_manager = state
325        .star_manager
326        .as_ref()
327        .ok_or(KipukaError::ServiceUnavailable(
328            "STAR manager not available".into(),
329        ))?;
330
331    // Fetch the current certificate (handles status checks internally).
332    match star_manager.get_current_certificate(&order_id) {
333        Ok(cert) => {
334            // Wrap in PKCS#7 certs-only per RFC 7030 §4.2.3.
335            let pkcs7_der = crate::routes::cacerts::build_certs_only_pkcs7(std::slice::from_ref(
336                &cert.certificate_der,
337            ))?;
338            let response_body = encode_est_base64(&pkcs7_der);
339
340            let mut resp = (StatusCode::OK, response_body).into_response();
341            resp.headers_mut().insert(
342                header::CONTENT_TYPE,
343                HeaderValue::from_static(content_types::PKCS7_CERTS),
344            );
345            resp.headers_mut().insert(
346                header::HeaderName::from_static("content-transfer-encoding"),
347                HeaderValue::from_static(content_types::TRANSFER_ENCODING_BASE64),
348            );
349            Ok(resp)
350        }
351        Err(StarError::OrderCancelled(_) | StarError::OrderExpired(_)) => {
352            // RFC 8739 §3.4: return 410 Gone for terminated orders.
353            Ok(StatusCode::GONE.into_response())
354        }
355        Err(StarError::OrderNotFound(_)) => Err(KipukaError::NotFound),
356        Err(e) => Err(KipukaError::Internal(e.to_string())),
357    }
358}
359
360/// `DELETE /.well-known/est/star/{order_id}`
361///
362/// Cancel a STAR order.  Future renewals are suppressed and the order
363/// status is set to `cancelled`.  Existing certificates remain valid
364/// until their natural expiry.
365///
366/// # Authentication
367///
368/// Requires EST authentication (mTLS or OTP).
369///
370/// # Response
371///
372/// | Status | Meaning                        |
373/// |--------|--------------------------------|
374/// | 204    | Order cancelled successfully   |
375/// | 404    | Order not found                |
376pub async fn delete_star_order(
377    auth: EstAuth,
378    Path(order_id): Path<String>,
379    State(state): State<Arc<AppState>>,
380) -> Result<Response, KipukaError> {
381    // Check that STAR is enabled.
382    let _star_config = state
383        .config
384        .star
385        .as_ref()
386        .filter(|c| c.enabled)
387        .ok_or(KipukaError::NotFound)?;
388
389    let star_manager = state
390        .star_manager
391        .as_ref()
392        .ok_or(KipukaError::ServiceUnavailable(
393            "STAR manager not available".into(),
394        ))?;
395
396    let identity = &auth.0.identity;
397
398    tracing::info!(
399        order_id = %order_id,
400        identity = %identity,
401        "STAR order cancellation request"
402    );
403
404    let _operation = star_manager.operation_guard().await;
405    let order = star_manager
406        .get_order(&order_id)
407        .ok_or(KipukaError::NotFound)?;
408    if order.requestor_dn.as_deref() != Some(identity.as_str()) {
409        return Err(KipukaError::Forbidden(
410            "only the order owner may cancel".into(),
411        ));
412    }
413    sqlx::query(crate::db::pg_sql(
414        "UPDATE star_orders SET status = 'cancelled', cancelled_at = ? WHERE id = ?",
415    ))
416    .bind(chrono::Utc::now().to_rfc3339())
417    .bind(&order_id)
418    .execute(&state.db)
419    .await?;
420    star_manager
421        .cancel_order(&order_id)
422        .map_err(star_error_to_kipuka)?;
423
424    state
425        .record_audit_event(
426            "star_order_cancelled",
427            &format!("order_id={order_id}, identity={identity}"),
428        )
429        .await;
430
431    Ok(StatusCode::NO_CONTENT.into_response())
432}
433
434/// `GET /.well-known/est/star/{order_id}/history`
435///
436/// List all certificates issued in the STAR renewal series, ordered by
437/// renewal number.  Returns a JSON array suitable for monitoring and
438/// auditing STAR certificate rotation.
439///
440/// # Response
441///
442/// | Header       | Value              |
443/// |--------------|--------------------|
444/// | Content-Type | `application/json` |
445///
446/// ```json
447/// [
448///   {
449///     "serial": "01AB...",
450///     "not_before": "2025-06-01T00:00:00Z",
451///     "not_after": "2025-06-02T00:00:00Z",
452///     "renewal_number": 0
453///   }
454/// ]
455/// ```
456pub async fn get_star_history(
457    Path(order_id): Path<String>,
458    State(state): State<Arc<AppState>>,
459) -> Result<Response, KipukaError> {
460    // Check that STAR is enabled.
461    let _star_config = state
462        .config
463        .star
464        .as_ref()
465        .filter(|c| c.enabled)
466        .ok_or(KipukaError::NotFound)?;
467
468    // Verify the order exists (check in-memory first, fall back to DB).
469    let star_manager = state.star_manager.as_ref();
470    let in_memory = star_manager.and_then(|m| m.get_order(&order_id)).is_some();
471
472    if !in_memory {
473        // Check DB as well — the order may have been cleaned from memory.
474        let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM star_orders WHERE id = ?")
475            .bind(&order_id)
476            .fetch_one(&state.db)
477            .await
478            .unwrap_or((0,));
479
480        if count.0 == 0 {
481            return Err(KipukaError::NotFound);
482        }
483    }
484
485    // Query all certificates in the series.
486    let rows: Vec<StarCertRow> = sqlx::query_as(
487        "SELECT serial_number, not_before, not_after, renewal_number \
488         FROM star_certificates WHERE star_order_id = ? ORDER BY renewal_number ASC",
489    )
490    .bind(&order_id)
491    .fetch_all(&state.db)
492    .await?;
493
494    let entries: Vec<serde_json::Value> = rows
495        .iter()
496        .map(|r| {
497            serde_json::json!({
498                "serial": r.serial_number,
499                "not_before": r.not_before,
500                "not_after": r.not_after,
501                "renewal_number": r.renewal_number,
502            })
503        })
504        .collect();
505
506    let json_body = serde_json::to_string(&entries)
507        .map_err(|e| KipukaError::Internal(format!("JSON serialization failed: {e}")))?;
508
509    let mut resp = (StatusCode::OK, json_body).into_response();
510    resp.headers_mut().insert(
511        header::CONTENT_TYPE,
512        HeaderValue::from_static("application/json"),
513    );
514
515    Ok(resp)
516}
517
518/// Row type for STAR certificate history queries.
519#[derive(sqlx::FromRow)]
520struct StarCertRow {
521    serial_number: String,
522    not_before: String,
523    not_after: String,
524    renewal_number: i64,
525}
526
527/// Map a [`StarError`] to a [`KipukaError`] for HTTP response generation.
528fn star_error_to_kipuka(e: StarError) -> KipukaError {
529    match e {
530        StarError::OrderNotFound(_) => KipukaError::NotFound,
531        StarError::OrderCancelled(id) => {
532            KipukaError::BadRequest(format!("STAR order {id} is cancelled"))
533        }
534        StarError::OrderExpired(id) => {
535            KipukaError::BadRequest(format!("STAR order {id} has expired"))
536        }
537        StarError::MaxRenewalsReached { order_id, max } => KipukaError::BadRequest(format!(
538            "STAR order {order_id} reached maximum renewals ({max})"
539        )),
540        StarError::MaxOrdersReached { limit } => {
541            KipukaError::ServiceUnavailable(format!("maximum active STAR orders reached ({limit})"))
542        }
543        StarError::InvalidInterval {
544            requested,
545            min,
546            max,
547        } => KipukaError::BadRequest(format!(
548            "renewal interval {requested}s outside allowed range {min}s–{max}s"
549        )),
550        StarError::IssuanceError(msg) => KipukaError::Ca(msg),
551        StarError::DatabaseError(msg) => KipukaError::Db(msg),
552    }
553}