revenant-sign-core 3.0.5

Cross-platform client library for ARX CoSign / DocuSign Signature Appliance electronic signatures via the OASIS DSS SOAP API
Documentation
//! ETSI Trust Service List (TSL) parser and cache.
//!
//! Parses TSL XML documents (ETSI TS 119 612) into the set of trust-anchor
//! certificates a country publishes for qualified electronic signatures, used
//! for PKI chain validation.
//!
//! The [`cache`] submodule centralizes trust-store caching in an injectable
//! [`TrustStoreCache`] -- the same DI seam used by the config layer -- so
//! callers own the cache and tests exercise every path (fresh hit, miss,
//! stale-on-error) without the network.

mod cache;

use std::time::Instant;

use base64::Engine as _;

use crate::xml::{find_all_nodes, find_all_values, find_node, find_value, parse_dom};
use crate::{Result, RevenantError};

pub use cache::{fetch_trust_store, TrustStoreCache};

/// Service type suffix marking a qualified certificate authority.
const CA_SERVICE_TYPE: &str = "CA/QC";
/// Service status suffixes that denote an active (trusted) service.
const ACTIVE_STATUSES: [&str; 3] = ["granted", "accredited", "undersupervision"];
/// Marker separating the service-type URI prefix from its meaningful suffix.
const SVCTYPE_MARKER: &str = "/Svctype/";

/// A trusted service entry extracted from a TSL.
#[derive(Debug, Clone)]
pub struct TrustAnchor {
    pub subject_name: String,
    pub service_name: String,
    pub service_type: String,
    pub status: String,
    pub cert_der: Vec<u8>,
}

/// Parsed trust anchors from a TSL, with metadata.
///
/// `fetched_at` is a monotonic [`Instant`], so cache-freshness checks are immune
/// to wall-clock adjustments.
#[derive(Debug, Clone)]
pub struct TrustStore {
    pub anchors: Vec<TrustAnchor>,
    pub ca_anchors: Vec<TrustAnchor>,
    pub scheme_operator: String,
    pub tsl_url: String,
    pub fetched_at: Instant,
}

impl TrustStore {
    /// Build a trust store from a pinned, bundled set of CA certificates (DER),
    /// with no network access.
    ///
    /// This is the offline, out-of-band way to establish trust for a deployment
    /// whose issuing CAs are not published in a usable Trust Service List -- the
    /// caller vouches for the certificates by bundling them. Certificates that
    /// fail to parse are skipped (a bundled anchor should always parse; this only
    /// guards against a malformed embed). Chain validation treats these anchors
    /// exactly like TSL-derived ones.
    #[must_use]
    pub fn from_pinned_cas(certs: &[Vec<u8>]) -> Self {
        let ca_anchors: Vec<TrustAnchor> = certs
            .iter()
            .filter_map(|der| {
                let cert = super::cert::parse_der(der).ok()?;
                let subject = super::cert::subject_dn(&cert);
                Some(TrustAnchor {
                    subject_name: subject.clone(),
                    service_name: subject,
                    service_type: "pinned".to_owned(),
                    status: "pinned".to_owned(),
                    cert_der: der.clone(),
                })
            })
            .collect();
        TrustStore {
            anchors: ca_anchors.clone(),
            ca_anchors,
            scheme_operator: "pinned trust anchors".to_owned(),
            tsl_url: String::new(),
            fetched_at: Instant::now(),
        }
    }
}

/// Parse an ETSI TSL XML document into a [`TrustStore`]. Pure -- no I/O.
///
/// # Errors
///
/// Returns [`RevenantError::Other`] if the bytes are not valid UTF-8 XML.
pub fn parse_tsl(xml_bytes: &[u8], tsl_url: &str) -> Result<TrustStore> {
    let xml = std::str::from_utf8(xml_bytes)
        .map_err(|e| RevenantError::Other(format!("TSL is not valid UTF-8: {e}")))?;
    let root = parse_dom(xml)
        .map_err(|e| RevenantError::Other(format!("Failed to parse TSL XML: {e}")))?;

    let scheme_operator = find_node(&root, "SchemeOperatorName")
        .and_then(|node| find_value(node, "Name"))
        .filter(|name| !name.is_empty())
        .unwrap_or_else(|| "Unknown".to_owned());

    let mut services = Vec::new();
    find_all_nodes(&root, "ServiceInformation", &mut services);

    let mut anchors = Vec::new();
    for svc in services {
        let type_uri = find_value(svc, "ServiceTypeIdentifier").unwrap_or_default();
        let service_type = service_type_suffix(&type_uri);

        let service_name = find_node(svc, "ServiceName")
            .and_then(|node| find_value(node, "Name"))
            .unwrap_or_default();

        let status = status_suffix(&find_value(svc, "ServiceStatus").unwrap_or_default());
        if !is_active_status(&status) {
            log::debug!("Skipping inactive service {service_name} (status={status})");
            continue;
        }

        // A service's certs all share the first published subject name.
        let subject_name = find_value(svc, "X509SubjectName").unwrap_or_default();

        let mut cert_b64s = Vec::new();
        find_all_values(svc, "X509Certificate", &mut cert_b64s);
        for b64 in cert_b64s {
            match decode_cert_b64(&b64) {
                Ok(cert_der) => anchors.push(TrustAnchor {
                    subject_name: subject_name.clone(),
                    service_name: service_name.clone(),
                    service_type: service_type.clone(),
                    status: status.clone(),
                    cert_der,
                }),
                Err(_) => log::warn!("Failed to decode certificate in service {service_name}"),
            }
        }
    }

    let ca_anchors: Vec<TrustAnchor> = anchors
        .iter()
        .filter(|anchor| anchor.service_type == CA_SERVICE_TYPE)
        .cloned()
        .collect();

    log::info!(
        "Parsed TSL: {} anchors ({} CA), operator={scheme_operator}",
        anchors.len(),
        ca_anchors.len(),
    );

    Ok(TrustStore {
        anchors,
        ca_anchors,
        scheme_operator,
        tsl_url: tsl_url.to_owned(),
        fetched_at: Instant::now(),
    })
}

/// Extract the meaningful suffix of a service-type URI, e.g.
/// `.../Svctype/CA/QC` -> `CA/QC`.
fn service_type_suffix(uri: &str) -> String {
    match uri.find(SVCTYPE_MARKER) {
        Some(idx) => uri[idx + SVCTYPE_MARKER.len()..].to_owned(),
        None => uri.to_owned(),
    }
}

/// Extract the status keyword of a status URI, e.g. `.../Svcstatus/granted`
/// -> `granted`.
fn status_suffix(uri: &str) -> String {
    match uri.rfind('/') {
        Some(idx) => uri[idx + 1..].to_owned(),
        None => uri.to_owned(),
    }
}

fn is_active_status(status: &str) -> bool {
    let lower = status.to_lowercase();
    ACTIVE_STATUSES.contains(&lower.as_str())
}

/// Decode a certificate's base64, tolerating the embedded newlines TSLs use to
/// wrap long values but rejecting genuinely invalid content.
fn decode_cert_b64(b64: &str) -> std::result::Result<Vec<u8>, base64::DecodeError> {
    let compact: String = b64.chars().filter(|c| !c.is_ascii_whitespace()).collect();
    base64::engine::general_purpose::STANDARD.decode(compact.as_bytes())
}

#[cfg(test)]
mod tests {
    use super::*;

    const TEST_CA_B64: &str = "MIICyTCCAbGgAwIBAgIUc+D/OLA1d/dW5cFrQg2HviXUFzowDQYJKoZIhvcNAQELBQAwFDESMBAGA1UEAwwJVGVzdCBDQSAxMB4XDTI0MDEwMTAwMDAwMFoXDTI1MDEwMTAwMDAwMFowFDESMBAGA1UEAwwJVGVzdCBDQSAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwNzyFsFN8OAvo2l99sjHg/PXt7OoGpDCywCFRq+NGcDhX7VemYoNESXNooYR2kU8rCOcKEOqi/l3ez87UKF9C2HDgs4+j/L9tuTUzGOcUTfBidSH99psSGJvUefg9pqq1j+D22wIL37JMBnW8ZxfkvXTlETCguURSaEkbm9tHMwx5l1Kd0PYiYLv+oU+ThQSa05Y8+Hd4bImolAZzA8WNqR469KF2SePq/rV6G8U1l6pYBEKdEAOXVNFq6sT/p0dN/CPwyant7bZXRcqejyG9UZrkuTniOJlL1LGxSI/J0JKkvAJgsAdqJOCk4mVneMmU3aYUM4UdoL0ZPQP3IqcpwIDAQABoxMwETAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAIyXXHFwIszwMoxYTe3SCOdtlE6bAwOEqaZBODLVXjNqgV16QvwVV2eL2Jox3Ya7ErVk7NsnRW2N8l1+mO5vF8a5aUxXdyyJ+Ht0PNkr3rmc739OgUzZgLikFVwJxpbADNuYC3gkWEDmBn4V08HW8x/yfFCPMLSTl9qpqUmjEpWPvJsMN9D1Vp9WaI1vAT5PSU8zzLeCQfRVFH6rrnsBRE2PUnmfx+r22M8yZl3cAlyciMyobdQtRy/KFru8/LXpWXKut/ZqC8aMoAuZJwJhhHFH2QrvIkav6Aqus8LBf6KNfhT96gnfI4N8/4UrmY/kEzapJPz414vJTb6S2bviK6";

    /// Build a one-service TSL document; `subject` omits `X509SubjectName` when
    /// `None`.
    fn tsl_xml(
        operator: &str,
        service: &str,
        status: &str,
        cert_b64: &str,
        subject: Option<&str>,
    ) -> Vec<u8> {
        let subject_el = subject.map_or_else(String::new, |s| {
            format!("<DigitalId><X509SubjectName>{s}</X509SubjectName></DigitalId>")
        });
        format!(
            r#"<?xml version="1.0" encoding="UTF-8"?>
<TrustServiceStatusList xmlns="http://uri.etsi.org/02231/v2#">
  <SchemeInformation><SchemeOperatorName><Name xml:lang="en">{operator}</Name></SchemeOperatorName></SchemeInformation>
  <TrustServiceProviderList><TrustServiceProvider><TSPServices><TSPService><ServiceInformation>
    <ServiceTypeIdentifier>http://uri.etsi.org/TrstSvc/Svctype/CA/QC</ServiceTypeIdentifier>
    <ServiceName><Name xml:lang="en">{service}</Name></ServiceName>
    <ServiceStatus>http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/{status}</ServiceStatus>
    <ServiceDigitalIdentity>
      <DigitalId><X509Certificate>{cert_b64}</X509Certificate></DigitalId>
      {subject_el}
    </ServiceDigitalIdentity>
  </ServiceInformation></TSPService></TSPServices></TrustServiceProvider></TrustServiceProviderList>
</TrustServiceStatusList>"#
        )
        .into_bytes()
    }

    #[test]
    fn parse_minimal_tsl() {
        let xml = tsl_xml(
            "Test Operator",
            "Test CA",
            "granted",
            TEST_CA_B64,
            Some("CN=Test CA 1"),
        );
        let store = parse_tsl(&xml, "https://example.com/tsl.xml").unwrap();
        assert_eq!(store.scheme_operator, "Test Operator");
        assert_eq!(store.tsl_url, "https://example.com/tsl.xml");
        assert_eq!(store.anchors.len(), 1);
        assert_eq!(store.ca_anchors.len(), 1);

        let anchor = &store.ca_anchors[0];
        assert_eq!(anchor.service_name, "Test CA");
        assert_eq!(anchor.service_type, "CA/QC");
        assert_eq!(anchor.status, "granted");
        assert_eq!(anchor.subject_name, "CN=Test CA 1");
        assert!(!anchor.cert_der.is_empty());
    }

    #[test]
    fn parse_empty_tsl() {
        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
<TrustServiceStatusList xmlns="http://uri.etsi.org/02231/v2#">
  <SchemeInformation><SchemeOperatorName><Name xml:lang="en">Empty</Name></SchemeOperatorName></SchemeInformation>
</TrustServiceStatusList>"#;
        let store = parse_tsl(xml, "").unwrap();
        assert_eq!(store.scheme_operator, "Empty");
        assert!(store.anchors.is_empty());
        assert!(store.ca_anchors.is_empty());
    }

    #[test]
    fn skips_inactive_services() {
        let xml = tsl_xml("Test", "Some CA", "withdrawn", "AAAA", None);
        assert!(parse_tsl(&xml, "").unwrap().anchors.is_empty());
    }

    #[test]
    fn malformed_xml_is_error() {
        assert!(parse_tsl(b"not xml at all", "").is_err());
    }

    #[test]
    fn invalid_base64_cert_is_skipped() {
        let xml = tsl_xml("Test", "Some CA", "granted", "!!!not-valid-base64!!!", None);
        assert!(parse_tsl(&xml, "").unwrap().anchors.is_empty());
    }

    #[test]
    fn missing_subject_name_parses_with_empty() {
        let xml = tsl_xml("Test", "Some CA", "granted", TEST_CA_B64, None);
        let store = parse_tsl(&xml, "").unwrap();
        assert_eq!(store.anchors.len(), 1);
        assert_eq!(store.anchors[0].subject_name, "");
    }

    #[test]
    fn service_type_suffix_without_marker_returns_uri() {
        assert_eq!(
            service_type_suffix("http://example.com/something"),
            "http://example.com/something"
        );
    }
}