Skip to main content

kipuka/auth/
mtls.rs

1//! mTLS client certificate authentication for EST endpoints.
2//!
3//! RFC 7030 §3.3.2: EST servers that support certificate-based client
4//! authentication extract the client certificate from the TLS session
5//! and validate it against the EST-dedicated truststore.
6//!
7//! This module handles:
8//!
9//! - Certificate extraction from the TLS session (request extension)
10//! - Validation against the EST truststore (separate from admin truststore,
11//!   per RHELBU-3536 R18)
12//! - Subject DN and SAN extraction for identity matching
13//! - EKU validation (id-kp-cmcRA for `/fullcmc`, per RHELBU-3536 R15)
14//! - OCSP/CRL revocation checking (RHELBU-3536 R21)
15//! - POP linking: extracting TLS client cert identity for CSR subject matching
16
17use std::sync::Arc;
18
19use axum::http::request::Parts;
20use tracing::{debug, info, warn};
21
22use synta_certificate::{
23    SignatureVerifier, cert_byte_ranges, crl::CertificateList, default_signature_verifier,
24};
25
26use super::{AuthMethod, AuthResult};
27use crate::ocsp::{OcspClient, OcspStatus};
28use crate::state::AppState;
29
30/// DER-encoded client certificate injected into request extensions by the
31/// TLS accept loop.
32///
33/// Absent when the TLS listener has no client-cert requirement or the client
34/// presented no certificate.
35#[derive(Clone, Debug)]
36pub struct PeerCertificate(pub Vec<u8>);
37
38/// Attempt to extract and validate an mTLS client certificate.
39///
40/// Returns `Some(AuthResult)` if a valid client certificate is present,
41/// `None` if no certificate was presented (allowing fallback to other
42/// auth methods).
43///
44/// # Certificate validation
45///
46/// The TLS layer (rustls `ClientCertVerifier`) has already validated the
47/// certificate chain against the EST truststore by the time this function
48/// runs.  This function performs additional EST-specific checks:
49///
50/// - Subject DN pattern matching (if configured per label)
51/// - SAN extraction for identity resolution
52/// - EKU extraction for CMC RA authorization
53/// - Revocation status check via OCSP stapling or CRL (RHELBU-3536 R21)
54pub async fn try_extract_mtls(parts: &Parts, _app: &Arc<AppState>) -> Option<AuthResult> {
55    let peer_cert = parts.extensions.get::<PeerCertificate>()?;
56
57    debug!("mTLS client certificate present, extracting identity");
58
59    // Parse the DER-encoded certificate via synta_certificate to extract
60    // subject DN, SANs, and EKU for identity resolution and authorization.
61    let cert_der = &peer_cert.0;
62
63    let subject_dn = extract_subject_dn(cert_der);
64    let sans = extract_subject_alt_names(cert_der);
65    let ekus = extract_extended_key_usage(cert_der);
66
67    // Build the identity string: prefer the first SAN if available,
68    // otherwise fall back to the subject DN.
69    let identity = sans
70        .first()
71        .cloned()
72        .or_else(|| subject_dn.clone())
73        .unwrap_or_else(|| "unknown".to_string());
74
75    if let Err(e) = check_revocation(cert_der, _app).await {
76        warn!(error = %e, identity = %identity, "certificate revocation check failed");
77        return None;
78    }
79
80    Some(AuthResult {
81        identity,
82        method: AuthMethod::Mtls,
83        client_cert_der: Some(cert_der.to_vec()),
84        subject_dn,
85        subject_alt_names: sans,
86        extended_key_usage: ekus,
87    })
88}
89
90/// Validate that the mTLS client certificate identity matches the CSR subject.
91///
92/// RFC 7030 §3.5 (Proof-of-Possession): for `/simplereenroll`, the TLS
93/// client certificate subject MUST match the CSR subject to prove the
94/// client possesses the private key corresponding to the certificate
95/// being renewed.
96///
97/// The full formatted distinguished names are trimmed and compared
98/// case-insensitively. SAN hostname matching cannot authorize a subject change.
99/// CSR signature verification is performed separately by the issuer.
100///
101/// Returns `Ok(())` if subjects match, `Err` with a description if not.
102pub fn validate_pop_linking(auth: &AuthResult, csr_subject: &str) -> Result<(), String> {
103    // Reenrollment preserves the complete distinguished name. Hostname SAN
104    // matching is not authorization to change other subject attributes.
105    let cert_subject = auth
106        .subject_dn
107        .as_deref()
108        .ok_or_else(|| "mTLS certificate has no subject DN for POP linking".to_string())?;
109
110    // Canonicalize for comparison: trim whitespace and compare case-insensitively.
111    let cert_norm = cert_subject.trim().to_lowercase();
112    let csr_norm = csr_subject.trim().to_lowercase();
113
114    if cert_norm != csr_norm {
115        return Err(format!(
116            "POP linking failed: TLS cert subject {cert_subject:?} does not match \
117             CSR subject {csr_subject:?}"
118        ));
119    }
120
121    Ok(())
122}
123
124/// Validate that the mTLS client certificate subject matches the CSR subject
125/// using simple string comparison (legacy API).
126///
127/// This is the equivalent comparison for callers holding raw subject strings.
128pub fn validate_pop_linking_simple(
129    client_cert_subject: Option<&str>,
130    csr_subject: &str,
131) -> Result<(), String> {
132    let cert_subject = client_cert_subject
133        .ok_or_else(|| "mTLS certificate has no subject DN for POP linking".to_string())?;
134
135    let cert_norm = cert_subject.trim().to_lowercase();
136    let csr_norm = csr_subject.trim().to_lowercase();
137
138    if cert_norm != csr_norm {
139        return Err(format!(
140            "POP linking failed: TLS cert subject {cert_subject:?} does not match \
141             CSR subject {csr_subject:?}"
142        ));
143    }
144
145    Ok(())
146}
147
148/// Validate certificate attribute matching against configured patterns.
149///
150/// RHELBU-3536 R19: the EST server MAY enforce that the client certificate
151/// matches configured subject DN patterns, SAN patterns, or issuer constraints.
152pub fn validate_cert_attributes(
153    auth: &AuthResult,
154    allowed_subject_patterns: &[String],
155    allowed_issuer_patterns: &[String],
156) -> Result<(), String> {
157    // If no patterns are configured, all certificates are accepted.
158    if allowed_subject_patterns.is_empty() && allowed_issuer_patterns.is_empty() {
159        return Ok(());
160    }
161
162    // Check subject DN patterns.
163    if !allowed_subject_patterns.is_empty() {
164        let subject = auth.subject_dn.as_deref().unwrap_or("");
165        let matches = allowed_subject_patterns
166            .iter()
167            .any(|pattern| subject.contains(pattern.as_str()));
168        if !matches {
169            return Err(format!(
170                "certificate subject {subject:?} does not match any allowed pattern"
171            ));
172        }
173    }
174
175    // Issuer pattern matching would require parsing the issuer DN from the
176    // certificate.  Not yet implemented.
177    let _ = allowed_issuer_patterns;
178
179    Ok(())
180}
181
182// ── Internal helpers ─────────────────────────────────────────────────────────
183
184/// Extract the subject DN from a DER-encoded certificate.
185///
186/// Parses the X.509 TBSCertificate via `synta_certificate` and formats
187/// the subject Name as an RFC 4514 distinguished name string.
188fn extract_subject_dn(cert_der: &[u8]) -> Option<String> {
189    if cert_der.is_empty() {
190        return None;
191    }
192    let cert = synta_certificate::Certificate::from_der(cert_der).ok()?;
193    let dn = synta_certificate::format_dn(cert.tbs_certificate.subject.0);
194    if dn.is_empty() || dn == "<invalid>" {
195        None
196    } else {
197        Some(dn)
198    }
199}
200
201/// Extract Subject Alternative Names from a DER-encoded certificate.
202///
203/// Parses the SAN extension (OID 2.5.29.17) from the X.509v3 extensions
204/// and returns human-readable strings for each GeneralName entry.
205fn extract_subject_alt_names(cert_der: &[u8]) -> Vec<String> {
206    let cert = match synta_certificate::Certificate::from_der(cert_der) {
207        Ok(c) => c,
208        Err(_) => return Vec::new(),
209    };
210
211    let mut result = Vec::new();
212    for (tag, content) in cert.subject_alt_names() {
213        let name = match tag {
214            synta_certificate::general_name::DNS_NAME => {
215                // dNSName — raw IA5String bytes
216                std::str::from_utf8(&content)
217                    .map(|s| s.to_string())
218                    .unwrap_or_else(|_| "DNS:<invalid UTF-8>".to_string())
219            }
220            synta_certificate::general_name::RFC822_NAME => {
221                // rfc822Name — email address
222                std::str::from_utf8(&content)
223                    .map(|s| s.to_string())
224                    .unwrap_or_else(|_| "email:<invalid UTF-8>".to_string())
225            }
226            synta_certificate::general_name::URI => {
227                // uniformResourceIdentifier
228                std::str::from_utf8(&content)
229                    .map(|s| s.to_string())
230                    .unwrap_or_else(|_| "URI:<invalid UTF-8>".to_string())
231            }
232            synta_certificate::general_name::IP_ADDRESS if content.len() == 4 => {
233                // IPv4 address
234                format!(
235                    "{}.{}.{}.{}",
236                    content[0], content[1], content[2], content[3]
237                )
238            }
239            synta_certificate::general_name::IP_ADDRESS if content.len() == 16 => {
240                // IPv6 address
241                let addr = std::net::Ipv6Addr::new(
242                    u16::from_be_bytes([content[0], content[1]]),
243                    u16::from_be_bytes([content[2], content[3]]),
244                    u16::from_be_bytes([content[4], content[5]]),
245                    u16::from_be_bytes([content[6], content[7]]),
246                    u16::from_be_bytes([content[8], content[9]]),
247                    u16::from_be_bytes([content[10], content[11]]),
248                    u16::from_be_bytes([content[12], content[13]]),
249                    u16::from_be_bytes([content[14], content[15]]),
250                );
251                addr.to_string()
252            }
253            synta_certificate::general_name::DIRECTORY_NAME => {
254                // directoryName — format as DN
255                format!("DirName:{}", synta_certificate::format_dn(&content))
256            }
257            _ => {
258                // Other types: include tag number for diagnostics
259                format!("GeneralName(tag={tag})")
260            }
261        };
262        result.push(name);
263    }
264    result
265}
266
267/// Extract Extended Key Usage OIDs from a DER-encoded certificate.
268///
269/// Parses the EKU extension (OID 2.5.29.37) from the X.509v3 extensions
270/// and returns each key purpose as a dotted-decimal OID string (e.g.
271/// `"1.3.6.1.5.5.7.3.28"` for id-kp-cmcRA).
272fn extract_extended_key_usage(cert_der: &[u8]) -> Vec<String> {
273    let cert = match synta_certificate::Certificate::from_der(cert_der) {
274        Ok(c) => c,
275        Err(_) => return Vec::new(),
276    };
277
278    // Get the raw extensions bytes.
279    let ext_raw = match cert.tbs_certificate.extensions.as_ref() {
280        Some(r) => r,
281        None => return Vec::new(),
282    };
283
284    // Find the EKU extension value within the extensions sequence.
285    let eku_bytes = match synta_certificate::find_extension_value(
286        ext_raw.as_bytes(),
287        synta_certificate::oids::EXTENDED_KEY_USAGE,
288    ) {
289        Some(bytes) => bytes,
290        None => return Vec::new(),
291    };
292
293    // EKU is a SEQUENCE OF ObjectIdentifier.
294    let mut decoder = synta::Decoder::new(eku_bytes, synta::Encoding::Der);
295    let oids: Vec<synta::ObjectIdentifier> = match decoder.decode() {
296        Ok(v) => v,
297        Err(_) => return Vec::new(),
298    };
299
300    oids.iter().map(|oid| oid.to_string()).collect()
301}
302
303/// Check certificate revocation status via OCSP or CRL.
304///
305/// RHELBU-3536 R21: the EST server SHOULD check the revocation status of
306/// client certificates presented for authentication.
307///
308/// Uses the [`OcspClient`] when OCSP is configured; falls back to CRL
309/// checking when the OCSP responder is unreachable and soft-fail is enabled.
310pub(crate) async fn check_revocation(cert_der: &[u8], app: &Arc<AppState>) -> Result<(), String> {
311    let ocsp_config = &app.config.ocsp;
312
313    if !ocsp_config.enabled {
314        debug!("OCSP checking disabled, skipping revocation check");
315        return Ok(());
316    }
317
318    let ocsp_client = OcspClient::new(ocsp_config.clone());
319
320    // The issuer certificate DER is needed for building the OCSP CertID.
321    // Resolve the actual signing issuer from configured certificate stores.
322    let issuer_der = super::certificate::issuer_for(cert_der, app)?;
323
324    match ocsp_client
325        .check_certificate_status(cert_der, &issuer_der)
326        .await
327    {
328        Ok(OcspStatus::Good) => {
329            info!("OCSP: certificate status is good");
330            Ok(())
331        }
332        Ok(OcspStatus::Revoked {
333            reason,
334            revocation_time,
335        }) => {
336            warn!(
337                reason = %reason,
338                revocation_time = %revocation_time,
339                "OCSP: certificate has been revoked"
340            );
341            Err(format!(
342                "certificate revoked: reason={reason}, time={revocation_time}"
343            ))
344        }
345        Ok(OcspStatus::Unknown) => {
346            warn!("OCSP: certificate status unknown");
347            if ocsp_config.soft_fail {
348                Ok(())
349            } else {
350                Err("OCSP: certificate status unknown".to_string())
351            }
352        }
353        Err(e) => {
354            warn!(error = %e, "OCSP check failed, attempting CRL fallback");
355            // Fall back to CRL checking if OCSP responder unreachable.
356            if ocsp_config.soft_fail {
357                info!("OCSP soft-fail enabled, accepting certificate despite OCSP failure");
358                Ok(())
359            } else {
360                // CRL fallback: try to fetch and check a CRL before giving up.
361                match check_crl_fallback(cert_der, app).await {
362                    Ok(()) => {
363                        info!("CRL fallback: certificate is not revoked");
364                        Ok(())
365                    }
366                    Err(crl_err) => {
367                        warn!(
368                            ocsp_error = %e,
369                            crl_error = %crl_err,
370                            "both OCSP and CRL revocation checks failed"
371                        );
372                        Err(format!(
373                            "OCSP check failed ({e}) and CRL fallback also failed ({crl_err})"
374                        ))
375                    }
376                }
377            }
378        }
379    }
380}
381
382/// Fallback revocation check via CRL Distribution Points.
383///
384/// Extracts the CDP extension from the client certificate, fetches the CRL
385/// via HTTP, verifies the CRL signature against the issuer, and checks
386/// whether the client cert's serial number appears in the revoked list.
387async fn check_crl_fallback(cert_der: &[u8], app: &Arc<AppState>) -> Result<(), String> {
388    // 1. Parse the client certificate and extract CDP URLs.
389    let cdp_urls = extract_cdp_http_urls(cert_der)?;
390    if cdp_urls.is_empty() {
391        return Err("certificate has no CRL Distribution Point HTTP URLs".to_string());
392    }
393
394    // 2. Extract the client cert serial number for revocation lookup.
395    let client_cert = synta_certificate::Certificate::from_der(cert_der)
396        .map_err(|e| format!("failed to parse client certificate: {e}"))?;
397    let client_serial = client_cert.tbs_certificate.serial_number.clone();
398
399    // 3. Get the issuer certificate for CRL signature verification.
400    let issuer_der = super::certificate::issuer_for(cert_der, app)?;
401    let issuer_ranges = cert_byte_ranges(&issuer_der)
402        .ok_or_else(|| "failed to extract byte ranges from issuer certificate".to_string())?;
403    let issuer_spki = &issuer_der[issuer_ranges.subject_public_key_info.clone()];
404
405    // 4. Try each CDP URL until one succeeds.
406    let mut last_err = String::new();
407    for url in &cdp_urls {
408        debug!(url = %url, "fetching CRL from distribution point");
409        match fetch_and_check_crl(
410            url,
411            &client_serial,
412            issuer_spki,
413            client_cert.tbs_certificate.issuer.as_bytes(),
414        )
415        .await
416        {
417            Ok(()) => return Ok(()),
418            Err(e) => {
419                if e.starts_with("certificate serial") {
420                    return Err(e);
421                }
422                warn!(url = %url, error = %e, "CRL check failed for this distribution point");
423                last_err = e;
424            }
425        }
426    }
427
428    Err(format!(
429        "all CRL distribution points failed; last error: {last_err}"
430    ))
431}
432
433/// Fetch a CRL from the given URL, verify its signature, and check
434/// whether the given serial number is in the revoked list.
435async fn fetch_and_check_crl(
436    url: &str,
437    client_serial: &synta::Integer,
438    issuer_spki_der: &[u8],
439    issuer_name_der: &[u8],
440) -> Result<(), String> {
441    // Fetch the CRL via HTTP with timeout and size limit.
442    let client = reqwest::Client::builder()
443        .timeout(std::time::Duration::from_secs(10))
444        .build()
445        .map_err(|e| format!("CRL client error: {e}"))?;
446    let mut response = client
447        .get(url)
448        .send()
449        .await
450        .map_err(|e| format!("HTTP fetch failed for {url}: {e}"))?;
451
452    if !response.status().is_success() {
453        return Err(format!(
454            "HTTP {} fetching CRL from {url}",
455            response.status()
456        ));
457    }
458
459    // Reject excessively large CRLs (> 10 MB) to prevent resource exhaustion.
460    if let Some(len) = response.content_length()
461        && len > 10_000_000
462    {
463        return Err(format!("CRL from {url} too large ({len} bytes, max 10 MB)"));
464    }
465
466    let mut crl_der = Vec::new();
467    while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
468        if chunk.len() > 10_000_000usize.saturating_sub(crl_der.len()) {
469            return Err("CRL body exceeds 10 MB".into());
470        }
471        crl_der.extend_from_slice(&chunk);
472    }
473
474    // Parse the CRL.
475    let crl =
476        CertificateList::from_der(&crl_der).map_err(|e| format!("failed to parse CRL DER: {e}"))?;
477
478    if crl
479        .tbs_cert_list
480        .issuer
481        .to_der()
482        .map_err(|e| e.to_string())?
483        != issuer_name_der
484    {
485        return Err("CRL issuer name does not match certificate issuer".into());
486    }
487    let now = chrono::Utc::now();
488    let this_update = super::certificate::time(&crl.tbs_cert_list.this_update)?;
489    let next_update = crl
490        .tbs_cert_list
491        .next_update
492        .as_ref()
493        .ok_or("CRL missing nextUpdate")?;
494    let next_update = super::certificate::time(next_update)?;
495    if this_update > now || next_update <= now || next_update <= this_update {
496        return Err("CRL is stale or future-dated".into());
497    }
498    if let Some(extensions) = &crl.tbs_cert_list.crl_extensions {
499        for ext in extensions {
500            let oid = ext.extn_id.to_string();
501            if oid == "2.5.29.27"
502                || oid == "2.5.29.28"
503                || ext.critical.as_ref().is_some_and(|v| v.0)
504            {
505                return Err("unsupported scoped, delta or critical CRL extension".into());
506            }
507        }
508    }
509
510    // Verify the CRL signature against the issuer's SPKI.
511    // The CRL has the same outer SEQUENCE { TBS, AlgId, Sig } structure
512    // as a Certificate, so we use the same byte-range extraction logic.
513    let crl_ranges = crl_byte_ranges(&crl_der)
514        .ok_or_else(|| "failed to extract byte ranges from CRL DER".to_string())?;
515
516    let tbs_bytes = &crl_der[crl_ranges.tbs];
517    let sig_alg_bytes = &crl_der[crl_ranges.sig_alg];
518    let sig_bits = &crl_der[crl_ranges.signature];
519
520    let verifier = default_signature_verifier();
521    verifier
522        .verify_certificate_signature(tbs_bytes, sig_alg_bytes, sig_bits, issuer_spki_der)
523        .map_err(|e| format!("CRL signature verification failed: {e}"))?;
524
525    info!("CRL signature verified successfully");
526
527    // Check if the client cert serial is in the revoked list.
528    if let Some(ref revoked_certs) = crl.tbs_cert_list.revoked_certificates {
529        for entry in revoked_certs {
530            if entry.user_certificate == *client_serial {
531                return Err(format!(
532                    "certificate serial {} is revoked (revocation date: {:?})",
533                    format_serial_hex(client_serial),
534                    entry.revocation_date,
535                ));
536            }
537        }
538    }
539
540    Ok(())
541}
542
543/// Extract HTTP URLs from the CRL Distribution Points extension.
544///
545/// Parses the CDP extension (OID 2.5.29.31) from the certificate's
546/// extensions and returns all `uniformResourceIdentifier` entries that
547/// use the `http://` or `https://` scheme.
548fn extract_cdp_http_urls(cert_der: &[u8]) -> Result<Vec<String>, String> {
549    let cert = synta_certificate::Certificate::from_der(cert_der)
550        .map_err(|e| format!("failed to parse certificate for CDP extraction: {e}"))?;
551
552    let ext_raw = cert
553        .tbs_certificate
554        .extensions
555        .as_ref()
556        .ok_or_else(|| "certificate has no extensions".to_string())?;
557
558    let cdp_bytes = synta_certificate::find_extension_value(
559        ext_raw.as_bytes(),
560        synta_certificate::oids::CRL_DISTRIBUTION_POINTS,
561    )
562    .ok_or_else(|| "CRL Distribution Points extension not found".to_string())?;
563
564    // CRLDistributionPoints ::= SEQUENCE OF DistributionPoint
565    // DistributionPoint ::= SEQUENCE {
566    //   distributionPoint [0] DistributionPointName OPTIONAL,
567    //   reasons           [1] ReasonFlags OPTIONAL,
568    //   cRLIssuer         [2] GeneralNames OPTIONAL
569    // }
570    // DistributionPointName ::= CHOICE {
571    //   fullName          [0] GeneralNames,
572    //   nameRelativeToCRLIssuer [1] RelativeDistinguishedName
573    // }
574    use synta::tag::TAG_SEQUENCE;
575    use synta::{Tag, TagClass};
576
577    let mut urls = Vec::new();
578    let seq_tag = Tag::universal_constructed(TAG_SEQUENCE);
579    let mut decoder = synta::Decoder::new(cdp_bytes, synta::Encoding::Der);
580    let mut outer = decoder
581        .enter_constructed(seq_tag)
582        .map_err(|e| format!("CDP outer SEQUENCE decode error: {e}"))?;
583
584    while !outer.is_empty() {
585        // Each element is a DistributionPoint SEQUENCE.
586        let mut dp = match outer.enter_constructed(seq_tag) {
587            Ok(d) => d,
588            Err(_) => break,
589        };
590        while !dp.is_empty() {
591            let dp_tag = match dp.read_tag() {
592                Ok(t) => t,
593                Err(_) => break,
594            };
595            let dp_len = match dp.read_length() {
596                Ok(l) => match l.definite() {
597                    Ok(n) => n,
598                    Err(_) => break,
599                },
600                Err(_) => break,
601            };
602            let dp_content = match dp.read_bytes(dp_len) {
603                Ok(c) => c,
604                Err(_) => break,
605            };
606
607            // distributionPoint is [0] — a DistributionPointName CHOICE.
608            if dp_tag.class() == TagClass::ContextSpecific && dp_tag.number() == 0 {
609                // fullName [0] IMPLICIT GeneralNames
610                let mut gn_dec = synta::Decoder::new(dp_content, synta::Encoding::Der);
611                while !gn_dec.is_empty() {
612                    let gn_tag = match gn_dec.read_tag() {
613                        Ok(t) => t,
614                        Err(_) => break,
615                    };
616                    let gn_len = match gn_dec.read_length() {
617                        Ok(l) => match l.definite() {
618                            Ok(n) => n,
619                            Err(_) => break,
620                        },
621                        Err(_) => break,
622                    };
623                    let gn_content = match gn_dec.read_bytes(gn_len) {
624                        Ok(c) => c,
625                        Err(_) => break,
626                    };
627
628                    // uniformResourceIdentifier [6] IMPLICIT IA5String
629                    if gn_tag.class() == TagClass::ContextSpecific
630                        && gn_tag.number() == 6
631                        && let Ok(uri) = std::str::from_utf8(gn_content)
632                        && (uri.starts_with("http://") || uri.starts_with("https://"))
633                    {
634                        urls.push(uri.to_string());
635                    }
636                }
637            }
638            // reasons [1] and cRLIssuer [2] are skipped.
639        }
640    }
641
642    Ok(urls)
643}
644
645/// Byte ranges within a DER-encoded CRL (same outer structure as Certificate).
646struct CrlByteRanges {
647    /// The complete `TBSCertList` TLV.
648    tbs: std::ops::Range<usize>,
649    /// The outer `signatureAlgorithm` TLV.
650    sig_alg: std::ops::Range<usize>,
651    /// The raw signature bytes from the `signatureValue` BIT STRING
652    /// (with the unused-bits byte stripped).
653    signature: std::ops::Range<usize>,
654}
655
656/// Extract byte ranges from a DER-encoded CRL for signature verification.
657///
658/// A CRL has the same `SEQUENCE { TBS, AlgorithmIdentifier, BIT STRING }`
659/// layout as a Certificate.
660fn crl_byte_ranges(crl_der: &[u8]) -> Option<CrlByteRanges> {
661    use synta::{Decoder, Encoding};
662
663    let mut d = Decoder::new(crl_der, Encoding::Der);
664
665    // Outer CertificateList SEQUENCE header.
666    d.read_tag().ok()?;
667    d.read_length().ok()?.definite().ok()?;
668
669    // TBSCertList: record start, skip.
670    let tbs_start = d.position();
671    d.read_tag().ok()?;
672    let tbs_content_len = d.read_length().ok()?.definite().ok()?;
673    let tbs_end = d.position() + tbs_content_len;
674    d.read_bytes(tbs_content_len).ok()?;
675
676    // signatureAlgorithm: record start, skip.
677    let sig_alg_start = d.position();
678    d.read_tag().ok()?;
679    let sig_alg_content_len = d.read_length().ok()?.definite().ok()?;
680    let sig_alg_end = d.position() + sig_alg_content_len;
681    d.read_bytes(sig_alg_content_len).ok()?;
682
683    // signatureValue BIT STRING: read tag+length, then skip unused-bits byte.
684    d.read_tag().ok()?;
685    let sig_len = d.read_length().ok()?.definite().ok()?;
686    if sig_len == 0 {
687        return None;
688    }
689    // The first byte of the BIT STRING content is the unused-bits count
690    // (always 0 for signatures). The actual signature starts at +1.
691    let sig_start = d.position() + 1;
692    let sig_end = d.position() + sig_len;
693
694    Some(CrlByteRanges {
695        tbs: tbs_start..tbs_end,
696        sig_alg: sig_alg_start..sig_alg_end,
697        signature: sig_start..sig_end,
698    })
699}
700
701/// Format a serial number as a colon-separated hex string for diagnostics.
702fn format_serial_hex(serial: &synta::Integer) -> String {
703    serial
704        .as_bytes()
705        .iter()
706        .map(|b| format!("{b:02x}"))
707        .collect::<Vec<_>>()
708        .join(":")
709}