1use 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
37pub 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
52pub 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 let star_config = state
93 .config
94 .star
95 .as_ref()
96 .filter(|c| c.enabled)
97 .ok_or(KipukaError::NotFound)?;
98
99 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 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 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 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 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 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 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 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 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
298pub async fn get_star_certificate(
313 Path(order_id): Path<String>,
314 State(state): State<Arc<AppState>>,
315) -> Result<Response, KipukaError> {
316 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 match star_manager.get_current_certificate(&order_id) {
333 Ok(cert) => {
334 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 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
360pub 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 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
434pub async fn get_star_history(
457 Path(order_id): Path<String>,
458 State(state): State<Arc<AppState>>,
459) -> Result<Response, KipukaError> {
460 let _star_config = state
462 .config
463 .star
464 .as_ref()
465 .filter(|c| c.enabled)
466 .ok_or(KipukaError::NotFound)?;
467
468 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 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 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#[derive(sqlx::FromRow)]
520struct StarCertRow {
521 serial_number: String,
522 not_before: String,
523 not_after: String,
524 renewal_number: i64,
525}
526
527fn 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}