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 [`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    // Phase 1: Remove expired orders.
72    let expired_count = star_manager.cleanup_expired();
73
74    // Phase 2: Find orders that need renewal.
75    let order_ids = star_manager.orders_needing_renewal();
76    if order_ids.is_empty() && expired_count == 0 {
77        debug!("no STAR orders need attention");
78        return;
79    }
80
81    let mut renewed = 0u32;
82    let mut failed = 0u32;
83
84    // Phase 3: Renew each eligible order.
85    for id in &order_ids {
86        let order = match star_manager.get_order(id) {
87            Some(o) => o,
88            None => {
89                debug!(order_id = %id, "order disappeared before renewal");
90                continue;
91            }
92        };
93
94        if order.status != StarOrderStatus::Active {
95            debug!(
96                order_id = %id,
97                status = ?order.status,
98                "skipping non-active order"
99            );
100            continue;
101        }
102
103        // Look up the issuing CA.
104        let ca = match cas.get(&order.ca_id) {
105            Some(ca) => ca,
106            None => {
107                warn!(
108                    order_id = %id,
109                    ca_id = %order.ca_id,
110                    "CA not found for STAR order — skipping"
111                );
112                failed += 1;
113                continue;
114            }
115        };
116
117        // Build an enrollment profile scoped to this renewal interval.
118        let validity_days = (order.renewal_interval.as_secs() as u32 / 86400).max(1);
119        let profile = EnrollmentProfile {
120            max_validity_days: validity_days,
121            ..EnrollmentProfile::default()
122        };
123
124        // Resolve key material — HSM-backed or PEM from disk.
125        let ca_cfg = ca_configs.iter().find(|c| c.id == order.ca_id);
126        let ca_key_pem: Vec<u8>;
127        let key_label_owned: String;
128
129        let signing_key = match ca_cfg {
130            Some(cfg) if cfg.is_hsm_backed() => match hsm {
131                Some(hsm_ctx) => {
132                    key_label_owned = match crate::routes::simpleenroll::parse_pkcs11_object_label(
133                        cfg.pkcs11_uri.as_deref().unwrap(),
134                    ) {
135                        Ok(l) => l,
136                        Err(e) => {
137                            warn!(
138                                order_id = %id,
139                                error = %e,
140                                "invalid pkcs11_uri for STAR renewal — skipping"
141                            );
142                            failed += 1;
143                            continue;
144                        }
145                    };
146                    issue::CaSigningKey::Hsm {
147                        context: hsm_ctx,
148                        key_label: &key_label_owned,
149                    }
150                }
151                None => {
152                    warn!(
153                        order_id = %id,
154                        ca_id = %order.ca_id,
155                        "HSM not configured but CA has pkcs11_uri — skipping"
156                    );
157                    failed += 1;
158                    continue;
159                }
160            },
161            Some(cfg) => match std::fs::read(&cfg.key_file) {
162                Ok(pem) => {
163                    ca_key_pem = pem;
164                    issue::CaSigningKey::Pem(&ca_key_pem)
165                }
166                Err(e) => {
167                    warn!(
168                        order_id = %id,
169                        ca_id = %order.ca_id,
170                        error = %e,
171                        "failed to read CA key for STAR renewal — skipping"
172                    );
173                    failed += 1;
174                    continue;
175                }
176            },
177            None => {
178                warn!(
179                    order_id = %id,
180                    ca_id = %order.ca_id,
181                    "CA config not found for STAR renewal — skipping"
182                );
183                failed += 1;
184                continue;
185            }
186        };
187
188        // Issue the renewed certificate.
189        match issue::issue_certificate(
190            &order.csr_der,
191            &profile,
192            &ca.cert_der,
193            signing_key,
194            &ca.hash_algorithm,
195        ) {
196            Ok(result) => {
197                let cert = StarCertificate {
198                    serial_number: result.serial_number.clone(),
199                    certificate_der: result.certificate_der.clone(),
200                    not_before: result.not_before,
201                    not_after: result.not_after,
202                    renewal_number: order.current_renewals + 1,
203                    star_order_id: id.clone(),
204                };
205
206                // Store the renewed certificate in the manager.
207                if let Err(e) = star_manager.store_renewed_certificate(id, cert.clone()) {
208                    warn!(
209                        order_id = %id,
210                        error = %e,
211                        "failed to store renewed certificate in manager"
212                    );
213                    failed += 1;
214                    continue;
215                }
216
217                // Persist to the database.
218                if let Err(e) = persist_certificate(db, id, &cert).await {
219                    error!(
220                        order_id = %id,
221                        serial = %cert.serial_number,
222                        error = %e,
223                        "failed to persist renewed certificate to database"
224                    );
225                    // Don't fail the renewal — the in-memory state is
226                    // already updated.  The DB will catch up on the next
227                    // successful write or via a reconciliation pass.
228                }
229
230                // Update the renewal counter in the database.
231                if let Err(e) = update_renewal_count(db, id, order.current_renewals + 1).await {
232                    error!(
233                        order_id = %id,
234                        error = %e,
235                        "failed to update renewal count in database"
236                    );
237                }
238
239                // Record the audit event.
240                crate::audit::record(
241                    db,
242                    audit,
243                    AuditEvent::new(AuditEventType::CertIssue)
244                        .with_ca_id(&order.ca_id)
245                        .with_detail(format!(
246                            "STAR renewal #{} for order {id}, serial={}, validity={validity_days}d",
247                            order.current_renewals + 1,
248                            result.serial_number,
249                        )),
250                )
251                .await;
252
253                info!(
254                    order_id = %id,
255                    serial = %result.serial_number,
256                    renewal = order.current_renewals + 1,
257                    validity_days,
258                    "STAR certificate renewed"
259                );
260                renewed += 1;
261            }
262            Err(e) => {
263                warn!(
264                    order_id = %id,
265                    ca_id = %order.ca_id,
266                    error = %e,
267                    "STAR certificate issuance failed — will retry next cycle"
268                );
269                failed += 1;
270            }
271        }
272    }
273
274    info!(
275        renewed,
276        failed,
277        expired = expired_count,
278        "STAR renewal cycle complete"
279    );
280}
281
282/// Insert a renewed certificate into the `star_certificates` table.
283async fn persist_certificate(
284    db: &sqlx::AnyPool,
285    order_id: &str,
286    cert: &StarCertificate,
287) -> Result<(), sqlx::Error> {
288    sqlx::query(
289        "INSERT INTO star_certificates \
290         (star_order_id, serial_number, certificate_der, not_before, not_after, renewal_number) \
291         VALUES (?, ?, ?, ?, ?, ?)",
292    )
293    .bind(order_id)
294    .bind(&cert.serial_number)
295    .bind(&cert.certificate_der)
296    .bind(cert.not_before.to_rfc3339())
297    .bind(cert.not_after.to_rfc3339())
298    .bind(cert.renewal_number as i64)
299    .execute(db)
300    .await?;
301
302    Ok(())
303}
304
305/// Update the current renewal count on a STAR order row.
306async fn update_renewal_count(
307    db: &sqlx::AnyPool,
308    order_id: &str,
309    count: u32,
310) -> Result<(), sqlx::Error> {
311    sqlx::query("UPDATE star_orders SET current_renewals = ? WHERE id = ?")
312        .bind(count as i64)
313        .bind(order_id)
314        .execute(db)
315        .await?;
316
317    Ok(())
318}