Skip to main content

kipuka/star/
renewal.rs

1//! Background renewal task for STAR certificates (RFC 8739).
2//!
3//! Spawns a tokio task that checks every 60 seconds for STAR orders
4//! with certificates approaching expiry.  When a certificate needs
5//! renewal, the task pre-generates the next certificate in the series
6//! via the CA subsystem and stores it for client retrieval.
7//!
8//! The renewal threshold is configurable via `pre_renewal_factor` in
9//! `[star]` config.  For example, with a 24-hour interval and factor
10//! 0.5, renewal happens when 12 hours remain on the current certificate.
11//!
12//! Failures are handled gracefully — a failed renewal is retried on the
13//! next 60-second cycle.  The task respects `max_renewals` limits and
14//! marks orders as `Completed` when the series is exhausted.
15
16use std::sync::Arc;
17use std::time::Duration;
18
19use tracing::{debug, error, info, warn};
20
21use crate::audit::{AuditEvent, AuditEventType, AuditState};
22use crate::ca::issue::{self, EnrollmentProfile};
23use crate::config::CaConfig;
24use crate::star::{StarCertificate, StarManager, StarOrderStatus};
25use crate::state::CaState;
26
27/// Spawn the background STAR certificate renewal task.
28///
29/// The returned [`tokio::task::JoinHandle`] can be used to abort the task during
30/// graceful shutdown.  The task runs indefinitely, ticking every 60
31/// seconds.
32///
33/// # Arguments
34///
35/// * `star_manager` - Shared STAR order manager
36/// * `db` - Database pool for persisting renewed certificates
37/// * `cas` - Map of CA states keyed by CA identifier
38/// * `ca_configs` - CA configurations for key material access
39/// * `hsm` - Optional HSM context for HSM-backed signing
40/// * `audit` - Shared audit state for event recording
41pub async fn spawn_renewal_task(
42    star_manager: Arc<StarManager>,
43    db: sqlx::AnyPool,
44    cas: Arc<indexmap::IndexMap<String, Arc<CaState>>>,
45    ca_configs: Arc<Vec<CaConfig>>,
46    hsm: Option<Arc<kipuka_hsm::HsmContext>>,
47    audit: Arc<AuditState>,
48) -> tokio::task::JoinHandle<()> {
49    tokio::spawn(async move {
50        let mut interval = tokio::time::interval(Duration::from_secs(60));
51
52        loop {
53            interval.tick().await;
54            renewal_cycle(&star_manager, &db, &cas, &ca_configs, hsm.as_ref(), &audit).await;
55        }
56    })
57}
58
59/// Execute a single renewal cycle: cleanup, then renew.
60async fn renewal_cycle(
61    star_manager: &StarManager,
62    db: &sqlx::AnyPool,
63    cas: &indexmap::IndexMap<String, Arc<CaState>>,
64    ca_configs: &[CaConfig],
65    hsm: Option<&Arc<kipuka_hsm::HsmContext>>,
66    audit: &AuditState,
67) {
68    let span = tracing::info_span!("star_renewal_cycle");
69    let _enter = span.enter();
70
71    let _guard = star_manager.operation_guard().await;
72    // Phase 1: Remove expired orders.
73    let expired_count = star_manager.cleanup_expired();
74
75    // Phase 2: Find orders that need renewal.
76    let order_ids = star_manager.orders_needing_renewal();
77    if order_ids.is_empty() && expired_count == 0 {
78        debug!("no STAR orders need attention");
79        return;
80    }
81
82    let mut renewed = 0u32;
83    let mut failed = 0u32;
84
85    // Phase 3: Renew each eligible order.
86    for id in &order_ids {
87        let order = match star_manager.get_order(id) {
88            Some(o) => o,
89            None => {
90                debug!(order_id = %id, "order disappeared before renewal");
91                continue;
92            }
93        };
94
95        if order.status != StarOrderStatus::Active {
96            debug!(
97                order_id = %id,
98                status = ?order.status,
99                "skipping non-active order"
100            );
101            continue;
102        }
103
104        // Look up the issuing CA.
105        let ca = match cas.get(&order.ca_id) {
106            Some(ca) => ca,
107            None => {
108                warn!(
109                    order_id = %id,
110                    ca_id = %order.ca_id,
111                    "CA not found for STAR order — skipping"
112                );
113                failed += 1;
114                continue;
115            }
116        };
117
118        // Build an enrollment profile scoped to this renewal interval.
119        let validity_secs = order.renewal_interval.as_secs();
120        let mut profile: EnrollmentProfile = match serde_json::from_str(&order.profile) {
121            Ok(profile) => profile,
122            Err(error) => {
123                warn!(order_id = %id, %error, "STAR order lacks persisted authorization policy");
124                failed += 1;
125                continue;
126            }
127        };
128        profile.max_validity_seconds = Some(validity_secs);
129        profile.max_not_after = Some(order.lifetime_end);
130        if let Err(error) = crate::audit::record_checked(
131            db,
132            audit,
133            AuditEvent::new(AuditEventType::EnrollRequest)
134                .with_ca_id(&order.ca_id)
135                .with_detail(format!("STAR renewal for {id}")),
136        )
137        .await
138        {
139            warn!(%error, "STAR renewal rejected by audit policy");
140            failed += 1;
141            continue;
142        }
143
144        // Resolve key material — HSM-backed or PEM from disk.
145        let ca_cfg = match ca_configs.iter().find(|c| c.id == order.ca_id) {
146            Some(cfg) => cfg,
147            None => {
148                warn!(
149                    order_id = %id,
150                    ca_id = %order.ca_id,
151                    "CA config not found for STAR renewal — skipping"
152                );
153                failed += 1;
154                continue;
155            }
156        };
157
158        let resolved_key = match issue::resolve_signing_key_sync(ca_cfg, hsm) {
159            Ok(k) => k,
160            Err(e) => {
161                warn!(
162                    order_id = %id,
163                    ca_id = %order.ca_id,
164                    error = %e,
165                    "failed to resolve signing key for STAR renewal — skipping"
166                );
167                failed += 1;
168                continue;
169            }
170        };
171
172        // Issue the renewed certificate.
173        match issue::issue_certificate(
174            &order.csr_der,
175            &profile,
176            &ca.cert_der,
177            resolved_key.as_signing_key(),
178            &ca.hash_algorithm,
179            ca.ocsp_url.as_deref(),
180            ca.crl_url.as_deref(),
181        ) {
182            Ok(result) => {
183                let cert = StarCertificate {
184                    serial_number: result.serial_number.clone(),
185                    certificate_der: result.certificate_der.clone(),
186                    not_before: result.not_before,
187                    not_after: result.not_after,
188                    renewal_number: order.current_renewals + 1,
189                    star_order_id: id.clone(),
190                };
191
192                let issuer_dn = match synta_certificate::Certificate::from_der(&ca.cert_der) {
193                    Ok(parsed) => synta_certificate::format_dn(parsed.tbs_certificate.subject.0),
194                    Err(error) => {
195                        warn!(%error, "cannot parse STAR issuer");
196                        failed += 1;
197                        continue;
198                    }
199                };
200                // Commit inventory, renewal and order progress atomically before publication.
201                if let Err(error) =
202                    persist_renewal(db, &order, &cert, &issuer_dn, &profile.name, audit).await
203                {
204                    error!(order_id = %id, %error, "STAR renewal transaction failed");
205                    failed += 1;
206                    continue;
207                }
208                if let Err(error) = star_manager.store_renewed_certificate(id, cert.clone()) {
209                    error!(order_id = %id, %error, "committed STAR renewal could not be published");
210                    failed += 1;
211                    continue;
212                }
213
214                info!(
215                    order_id = %id,
216                    serial = %result.serial_number,
217                    renewal = order.current_renewals + 1,
218                    validity_secs,
219                    "STAR certificate renewed"
220                );
221                renewed += 1;
222            }
223            Err(e) => {
224                warn!(
225                    order_id = %id,
226                    ca_id = %order.ca_id,
227                    error = %e,
228                    "STAR certificate issuance failed — will retry next cycle"
229                );
230                failed += 1;
231            }
232        }
233    }
234
235    info!(
236        renewed,
237        failed,
238        expired = expired_count,
239        "STAR renewal cycle complete"
240    );
241}
242
243/// Commit one renewal with compare-and-set progress to prevent duplicate publication.
244async fn persist_renewal(
245    db: &sqlx::AnyPool,
246    order: &crate::star::StarOrder,
247    cert: &StarCertificate,
248    issuer_dn: &str,
249    profile_name: &str,
250    audit: &AuditState,
251) -> Result<(), crate::error::KipukaError> {
252    let mut tx = db.begin().await?;
253    let sql = if cert.not_after.timestamp() >= order.lifetime_end.timestamp() {
254        "UPDATE star_orders SET current_renewals = ?, status = 'completed' WHERE id = ? AND current_renewals = ? AND status = 'active'"
255    } else {
256        "UPDATE star_orders SET current_renewals = ?, status = 'active' WHERE id = ? AND current_renewals = ? AND status = 'active'"
257    };
258    let updated = sqlx::query(crate::db::pg_sql(sql))
259        .bind(cert.renewal_number as i64)
260        .bind(&order.id)
261        .bind(order.current_renewals as i64)
262        .execute(&mut *tx)
263        .await?;
264    if updated.rows_affected() != 1 {
265        return Err(sqlx::Error::RowNotFound.into());
266    }
267    sqlx::query(crate::db::pg_sql(
268        "INSERT INTO star_certificates (star_order_id, serial_number, certificate_der, not_before, not_after, renewal_number) VALUES (?, ?, ?, ?, ?, ?)"))
269        .bind(&order.id).bind(&cert.serial_number).bind(&cert.certificate_der)
270        .bind(cert.not_before.to_rfc3339()).bind(cert.not_after.to_rfc3339())
271        .bind(cert.renewal_number as i64).execute(&mut *tx).await?;
272    sqlx::query(crate::db::pg_sql(
273        "INSERT INTO certificates (serial, subject_dn, issuer_dn, not_before, not_after, der_encoded, ca_id, profile, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')"))
274        .bind(&cert.serial_number).bind(&order.subject_dn).bind(issuer_dn)
275        .bind(cert.not_before.to_rfc3339()).bind(cert.not_after.to_rfc3339())
276        .bind(&cert.certificate_der).bind(&order.ca_id).bind(profile_name)
277        .execute(&mut *tx).await?;
278    crate::audit::record_issuance_in_transaction(
279        &mut tx,
280        audit,
281        &order.ca_id,
282        format!(
283            "STAR renewal #{} for order {}, serial={}",
284            cert.renewal_number, order.id, cert.serial_number
285        ),
286    )
287    .await?;
288    crate::audit::commit_issuance(tx, audit).await?;
289    Ok(())
290}