1use std::sync::Arc;
23
24use axum::extract::{Path, State};
25use axum::http::{HeaderValue, StatusCode, header};
26use axum::response::{IntoResponse, Response};
27use base64::Engine as _;
28use chrono::{DateTime, Duration, Utc};
29
30use crate::error::KipukaError;
31use crate::state::AppState;
32
33pub async fn get_renewal_info(
60 Path(cert_id): Path<String>,
61 State(state): State<Arc<AppState>>,
62) -> Result<Response, KipukaError> {
63 tracing::debug!(cert_id = %cert_id, "renewal-info request");
64
65 if cert_id.len() > 256 {
69 tracing::debug!(
70 cert_id_len = cert_id.len(),
71 "cert_id exceeds 256-character limit"
72 );
73 return Err(KipukaError::BadRequest(
74 "cert_id exceeds maximum length".into(),
75 ));
76 }
77
78 let (aki_b64, serial_b64) = cert_id.split_once('.').ok_or_else(|| {
80 tracing::debug!(cert_id = %cert_id, "cert_id missing '.' separator");
81 KipukaError::BadRequest("cert_id must be base64url(AKI) '.' base64url(Serial)".into())
82 })?;
83
84 let expected_aki = base64::engine::general_purpose::URL_SAFE_NO_PAD
85 .decode(aki_b64)
86 .map_err(|e| {
87 tracing::debug!(error = %e, "invalid base64url in AKI component");
88 KipukaError::BadRequest(format!("invalid base64url in AKI component: {e}"))
89 })?;
90
91 let serial_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
92 .decode(serial_b64)
93 .map_err(|e| {
94 tracing::debug!(error = %e, "invalid base64url in serial component");
95 KipukaError::BadRequest(format!("invalid base64url in serial component: {e}"))
96 })?;
97
98 let serial_hex = hex::encode(&serial_bytes);
100
101 tracing::debug!(
102 serial_hex = %serial_hex,
103 aki_len = expected_aki.len(),
104 "parsed cert_id components"
105 );
106
107 let row = sqlx::query_as::<_, CertRenewalRow>(crate::db::pg_sql(
109 "SELECT serial, not_after, der_encoded \
110 FROM certificates WHERE serial = ? AND status = 'active'",
111 ))
112 .bind(&serial_hex)
113 .fetch_optional(&state.db_ro)
114 .await
115 .map_err(|e| {
116 tracing::error!(error = %e, serial = %serial_hex, "certificate query failed");
117 KipukaError::Db(format!("certificate query failed: {e}"))
118 })?;
119
120 let row = row.ok_or_else(|| {
121 tracing::debug!(serial = %serial_hex, "certificate not found or not active");
122 KipukaError::NotFound
123 })?;
124
125 let actual_aki = extract_aki_key_id(&row.der_encoded).ok_or_else(|| {
127 tracing::debug!(
128 serial = %serial_hex,
129 "certificate does not contain an AKI keyIdentifier"
130 );
131 KipukaError::NotFound
132 })?;
133
134 if actual_aki != expected_aki {
137 tracing::debug!(
138 serial = %serial_hex,
139 "AKI mismatch — cert_id references wrong issuer"
140 );
141 state
143 .record_audit_event(
144 "renewal_info_aki_mismatch",
145 &format!("serial={serial_hex} aki_mismatch"),
146 )
147 .await;
148 return Err(KipukaError::NotFound);
149 }
150
151 let not_after = parse_not_after(&row.not_after).ok_or_else(|| {
153 tracing::error!(
154 serial = %serial_hex,
155 not_after = %row.not_after,
156 "failed to parse not_after timestamp"
157 );
158 KipukaError::Internal("failed to parse certificate expiry".into())
159 })?;
160
161 let window_days = std::cmp::max(state.config.est.renewal_window_days, 2) as i64;
165 let window_start = not_after - Duration::days(window_days);
166 let window_end = not_after - Duration::days(1);
167
168 debug_assert!(
170 window_start < window_end,
171 "renewal window start must precede end"
172 );
173
174 let body = serde_json::json!({
175 "suggestedWindow": {
176 "start": window_start.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
177 "end": window_end.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
178 }
179 });
180
181 tracing::debug!(
182 serial = %serial_hex,
183 window_start = %window_start,
184 window_end = %window_end,
185 "renewal-info response"
186 );
187
188 state
190 .record_audit_event("renewal_info_query", &format!("serial={serial_hex}"))
191 .await;
192
193 let retry_after = state.config.est.renewal_retry_after_secs;
195
196 let mut resp = (StatusCode::OK, body.to_string()).into_response();
197 resp.headers_mut().insert(
198 header::CONTENT_TYPE,
199 HeaderValue::from_static("application/json"),
200 );
201 if let Ok(hv) = HeaderValue::from_str(&retry_after.to_string()) {
202 resp.headers_mut().insert(header::RETRY_AFTER, hv);
203 }
204
205 Ok(resp)
206}
207
208#[derive(sqlx::FromRow)]
212struct CertRenewalRow {
213 #[allow(dead_code)]
214 serial: String,
215 not_after: String,
216 der_encoded: Vec<u8>,
217}
218
219fn extract_aki_key_id(cert_der: &[u8]) -> Option<Vec<u8>> {
235 let cert = synta_certificate::Certificate::from_der(cert_der).ok()?;
236 let ext_raw = cert.tbs_certificate.extensions.as_ref()?;
237 let aki_der = synta_certificate::find_extension_value(
238 ext_raw.as_bytes(),
239 synta_certificate::oids::AUTHORITY_KEY_IDENTIFIER,
240 )?;
241
242 parse_aki_key_identifier(aki_der)
253}
254
255fn parse_aki_key_identifier(aki_der: &[u8]) -> Option<Vec<u8>> {
257 if aki_der.len() < 2 {
260 return None;
261 }
262
263 let mut pos = 0;
264
265 if aki_der[pos] != 0x30 {
267 return None;
268 }
269 pos += 1;
270
271 let (seq_len, len_bytes) = parse_der_length(&aki_der[pos..])?;
273 pos += len_bytes;
274
275 let seq_end = pos + seq_len;
276 if seq_end > aki_der.len() {
277 return None;
278 }
279
280 while pos < seq_end {
282 if pos >= aki_der.len() {
283 break;
284 }
285 let tag = aki_der[pos];
286 pos += 1;
287
288 let (field_len, lb) = parse_der_length(&aki_der[pos..])?;
289 pos += lb;
290
291 if tag == 0x80 {
292 if pos + field_len > aki_der.len() {
294 return None;
295 }
296 return Some(aki_der[pos..pos + field_len].to_vec());
297 }
298
299 pos += field_len;
301 }
302
303 None
304}
305
306fn parse_der_length(data: &[u8]) -> Option<(usize, usize)> {
308 if data.is_empty() {
309 return None;
310 }
311
312 let first = data[0];
313 if first < 0x80 {
314 Some((first as usize, 1))
316 } else if first == 0x80 {
317 None
319 } else {
320 let num_bytes = (first & 0x7F) as usize;
322 if num_bytes > 4 || num_bytes + 1 > data.len() {
323 return None;
324 }
325 let mut length: usize = 0;
326 for i in 0..num_bytes {
327 length = length.checked_shl(8)?.checked_add(data[1 + i] as usize)?;
328 }
329 Some((length, 1 + num_bytes))
330 }
331}
332
333fn parse_not_after(s: &str) -> Option<DateTime<Utc>> {
338 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
340 return Some(dt.with_timezone(&Utc));
341 }
342 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
344 return Some(dt.and_utc());
345 }
346 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
348 return Some(dt.and_utc());
349 }
350 None
351}
352
353#[cfg(test)]
356mod tests {
357 use super::*;
358 use chrono::Datelike;
359 use chrono::Timelike;
360
361 #[test]
362 fn parse_aki_from_well_formed_der() {
363 let aki_der: &[u8] = &[
369 0x30, 0x06, 0x80, 0x04, 0xDE, 0xAD, 0xBE, 0xEF,
372 ];
373
374 let key_id = parse_aki_key_identifier(aki_der).unwrap();
375 assert_eq!(key_id, vec![0xDE, 0xAD, 0xBE, 0xEF]);
376 }
377
378 #[test]
379 fn parse_aki_missing_key_id_returns_none() {
380 let aki_der: &[u8] = &[
382 0x30, 0x04, 0xA1, 0x02, 0x00, 0x00,
385 ];
386
387 assert!(parse_aki_key_identifier(aki_der).is_none());
388 }
389
390 #[test]
391 fn parse_aki_empty_returns_none() {
392 assert!(parse_aki_key_identifier(&[]).is_none());
393 }
394
395 #[test]
396 fn parse_not_after_rfc3339() {
397 let dt = parse_not_after("2026-07-25T00:00:00Z").unwrap();
398 assert_eq!(dt.year(), 2026);
399 assert_eq!(dt.month(), 7);
400 assert_eq!(dt.day(), 25);
401 }
402
403 #[test]
404 fn parse_not_after_no_tz() {
405 let dt = parse_not_after("2026-07-25T12:30:00").unwrap();
406 assert_eq!(dt.hour(), 12);
407 }
408
409 #[test]
410 fn parse_not_after_invalid_returns_none() {
411 assert!(parse_not_after("not-a-date").is_none());
412 }
413
414 #[test]
415 fn der_length_short_form() {
416 let (len, consumed) = parse_der_length(&[0x04]).unwrap();
417 assert_eq!(len, 4);
418 assert_eq!(consumed, 1);
419 }
420
421 #[test]
422 fn der_length_long_form_two_bytes() {
423 let (len, consumed) = parse_der_length(&[0x82, 0x01, 0x00]).unwrap();
425 assert_eq!(len, 256);
426 assert_eq!(consumed, 3);
427 }
428
429 #[test]
430 fn base64url_roundtrip() {
431 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
432
433 let data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
434 let encoded = URL_SAFE_NO_PAD.encode(&data);
435 let decoded = URL_SAFE_NO_PAD.decode(&encoded).unwrap();
436 assert_eq!(data, decoded);
437 }
438}