Skip to main content

kipuka/routes/
renewal_info.rs

1//! `GET /.well-known/est/renewal-info/{cert_id}` — Certificate Renewal Info.
2//!
3//! Implements draft-ietf-lamps-est-renewal-info: an unauthenticated endpoint
4//! that returns a JSON object with a suggested renewal window for a
5//! certificate identified by its `cert_id`.
6//!
7//! The `cert_id` is constructed as:
8//!
9//! ```text
10//!   base64url(AKI.keyIdentifier) + "." + base64url(Serial)
11//! ```
12//!
13//! where the base64url encoding uses the URL-safe alphabet without padding
14//! (RFC 4648 §5).
15//!
16//! # Authentication
17//!
18//! No authentication is required.  The cert_id binds the request to a
19//! specific certificate, and the AKI component prevents serial-number
20//! enumeration against the wrong issuer.
21
22use 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
33/// `GET /.well-known/est/renewal-info/{cert_id}`
34///
35/// Returns a JSON object with a suggested renewal window for the
36/// identified certificate.
37///
38/// # Response
39///
40/// | Header         | Value                |
41/// |----------------|----------------------|
42/// | Status         | `200 OK`             |
43/// | Content-Type   | `application/json`   |
44/// | Retry-After    | configurable seconds |
45///
46/// ```json
47/// {
48///   "suggestedWindow": {
49///     "start": "2026-07-20T00:00:00Z",
50///     "end": "2026-07-24T00:00:00Z"
51///   }
52/// }
53/// ```
54///
55/// # Errors
56///
57/// - `400 Bad Request` — malformed cert_id
58/// - `404 Not Found` — certificate not found or AKI mismatch
59pub 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    // Reject oversized cert_id values.  A legitimate cert_id is
66    // base64url(AKI ~20 bytes) + "." + base64url(serial ~20 bytes) ≈ 55 chars.
67    // Cap at 256 to prevent memory amplification from the base64 decode.
68    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    // Step 1: Parse the cert_id — split on "." into AKI and serial parts.
79    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    // Convert serial bytes to the hex string used in the DB.
99    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    // Step 2: Query the certificate from the database by serial number.
108    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    // Step 3: Extract AKI keyIdentifier from the certificate DER.
126    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    // Step 4: Verify that the AKI matches the one in the cert_id.
135    // This prevents serial-number enumeration against a different issuer.
136    if actual_aki != expected_aki {
137        tracing::debug!(
138            serial = %serial_hex,
139            "AKI mismatch — cert_id references wrong issuer"
140        );
141        // Audit AKI mismatch — potential issuer enumeration attempt (NIAP FAU_GEN.1).
142        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    // Step 5: Calculate the renewal window.
152    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    // Clamp window_days to a minimum of 2 so that `start < end` always holds
162    // (the draft MUST NOT have end <= start).  With window_days=1 both would
163    // equal `not_after - 1d`; with window_days=0, end would precede start.
164    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    // Defensive: ensure start < end even if Duration arithmetic overflows.
169    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    // Audit successful renewal-info query (NIAP FAU_GEN.1).
189    state
190        .record_audit_event("renewal_info_query", &format!("serial={serial_hex}"))
191        .await;
192
193    // Step 6: Build the HTTP response.
194    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// ── Helpers ──────────────────────────────────────────────────────────────────
209
210/// Database row for the renewal-info query.
211#[derive(sqlx::FromRow)]
212struct CertRenewalRow {
213    #[allow(dead_code)]
214    serial: String,
215    not_after: String,
216    der_encoded: Vec<u8>,
217}
218
219/// Extract the AKI `keyIdentifier` from a DER-encoded certificate.
220///
221/// The Authority Key Identifier extension (OID 2.5.29.35) is encoded as:
222///
223/// ```text
224/// AuthorityKeyIdentifier ::= SEQUENCE {
225///     keyIdentifier       [0] KeyIdentifier OPTIONAL,
226///     ...
227/// }
228/// KeyIdentifier ::= OCTET STRING
229/// ```
230///
231/// The extension value from `find_extension_value` is the DER encoding of
232/// `AuthorityKeyIdentifier`.  We parse the outer SEQUENCE and extract the
233/// `[0]` implicit OCTET STRING.
234fn 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 the AKI SEQUENCE to extract keyIdentifier [0].
243    //
244    // The DER encoding is:
245    //   SEQUENCE {
246    //     [0] IMPLICIT OCTET STRING (keyIdentifier)   -- tag 0x80
247    //     ...optional fields...
248    //   }
249    //
250    // We walk the raw bytes: skip the SEQUENCE tag+length, then look for
251    // a context-specific tag [0] (0x80).
252    parse_aki_key_identifier(aki_der)
253}
254
255/// Parse the keyIdentifier from the raw DER of an AuthorityKeyIdentifier.
256fn parse_aki_key_identifier(aki_der: &[u8]) -> Option<Vec<u8>> {
257    // The aki_der should be the content of the OCTET STRING wrapping
258    // the extension value, which is a SEQUENCE.
259    if aki_der.len() < 2 {
260        return None;
261    }
262
263    let mut pos = 0;
264
265    // Expect SEQUENCE tag (0x30).
266    if aki_der[pos] != 0x30 {
267        return None;
268    }
269    pos += 1;
270
271    // Parse the SEQUENCE length.
272    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    // Look for [0] IMPLICIT (tag 0x80) within the SEQUENCE.
281    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            // Found keyIdentifier [0].
293            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        // Skip this field.
300        pos += field_len;
301    }
302
303    None
304}
305
306/// Parse a DER length field.  Returns `(length, bytes_consumed)`.
307fn 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        // Short form: single byte.
315        Some((first as usize, 1))
316    } else if first == 0x80 {
317        // Indefinite length — not valid in DER.
318        None
319    } else {
320        // Long form: first byte gives the number of subsequent length bytes.
321        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
333/// Parse a `not_after` timestamp string from the database.
334///
335/// The DB stores timestamps as ISO 8601 strings (e.g.,
336/// `"2026-07-25T00:00:00Z"`).  We try multiple common formats.
337fn parse_not_after(s: &str) -> Option<DateTime<Utc>> {
338    // Try RFC 3339 first (most common in our DB).
339    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
340        return Some(dt.with_timezone(&Utc));
341    }
342    // Try the format without timezone suffix (assume UTC).
343    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
344        return Some(dt.and_utc());
345    }
346    // Try with fractional seconds.
347    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// ── Tests ────────────────────────────────────────────────────────────────────
354
355#[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        // Minimal AKI SEQUENCE with keyIdentifier [0] = 0xDE 0xAD 0xBE 0xEF.
364        //
365        // SEQUENCE {
366        //   [0] IMPLICIT OCTET STRING (4 bytes: DE AD BE EF)
367        // }
368        let aki_der: &[u8] = &[
369            0x30, 0x06, // SEQUENCE, length 6
370            0x80, 0x04, // [0] IMPLICIT, length 4
371            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        // AKI SEQUENCE with only authorityCertIssuer [1] — no keyIdentifier.
381        let aki_der: &[u8] = &[
382            0x30, 0x04, // SEQUENCE, length 4
383            0xA1, 0x02, // [1] CONSTRUCTED, length 2
384            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        // 0x82 means 2 subsequent bytes encode the length.
424        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}