use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
use crate::transport::egress::validate_egress_url;
use roxmltree::Document;
pub const PEPPOL_AS4_TRANSPORT_PROFILE: &str = "peppol-transport-as4-v2_0";
pub const PEPPOL_PARTICIPANT_SCHEME: &str = "iso6523-actorid-upis";
#[derive(Debug, Clone)]
pub struct SmpConfig {
pub sml_zone: String,
pub participant_scheme: String,
pub transport_profile: String,
pub signature_policy: SmpSignaturePolicy,
}
impl SmpConfig {
pub fn peppol_test() -> Self {
Self {
sml_zone: "acc.edelivery.tech.ec.europa.eu".to_string(),
participant_scheme: PEPPOL_PARTICIPANT_SCHEME.to_string(),
transport_profile: PEPPOL_AS4_TRANSPORT_PROFILE.to_string(),
signature_policy: SmpSignaturePolicy::Deny,
}
}
pub fn peppol_production() -> Self {
Self {
sml_zone: "edelivery.tech.ec.europa.eu".to_string(),
participant_scheme: PEPPOL_PARTICIPANT_SCHEME.to_string(),
transport_profile: PEPPOL_AS4_TRANSPORT_PROFILE.to_string(),
signature_policy: SmpSignaturePolicy::Deny,
}
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub enum SmpSignaturePolicy {
#[default]
Deny,
Verify(Box<crate::crypto::wssec::OwnedRevocationPolicy>),
RequireSignaturePresent,
AllowUnsigned,
}
impl SmpSignaturePolicy {
pub fn verify_with_trust_anchors(trust_anchor_pems: Vec<String>) -> Self {
Self::Verify(Box::new(
crate::crypto::wssec::OwnedRevocationPolicy::production(trust_anchor_pems),
))
}
}
#[derive(Debug, Clone)]
pub struct SmpEndpoint {
pub url: String,
pub verified_signer_fingerprint_sha256: Option<String>,
pub signed_document: std::sync::Arc<[u8]>,
pub certificate_der_b64: Option<String>,
pub transport_profile: String,
pub service_description: Option<String>,
pub service_activation_date: Option<String>,
pub service_expiration_date: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SmpLookupRequest {
pub participant_id: String,
pub document_type_id: String,
pub process_id: String,
pub transport_profile: Option<String>,
}
#[derive(Clone)]
pub struct SmpClient {
config: SmpConfig,
http: reqwest::Client,
}
impl SmpClient {
pub fn new(sml_zone: impl Into<String>) -> Self {
Self::with_config(SmpConfig {
sml_zone: sml_zone.into(),
participant_scheme: PEPPOL_PARTICIPANT_SCHEME.to_string(),
transport_profile: PEPPOL_AS4_TRANSPORT_PROFILE.to_string(),
signature_policy: SmpSignaturePolicy::Deny,
})
}
pub fn with_config(config: SmpConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("failed to build SMP reqwest client");
Self { config, http }
}
pub fn config(&self) -> &SmpConfig {
&self.config
}
pub async fn lookup_endpoint(&self, req: SmpLookupRequest) -> Result<SmpEndpoint> {
let url = self.build_lookup_url(&req);
validate_egress_url(&url, "smp_lookup").await?;
let response = self.http.get(&url).send().await.map_err(|e| {
AsxError::new(
ErrorCode::TransportFailure,
format!("SMP HTTP request failed for '{url}': {e}"),
ErrorContext::new("smp_lookup"),
)
})?;
let status = response.status();
if !status.is_success() {
return Err(AsxError::new(
ErrorCode::NotFound,
format!(
"SMP returned HTTP {status} for participant '{}' / doc-type '{}'",
req.participant_id, req.document_type_id
),
ErrorContext::new("smp_lookup"),
));
}
let body = response.bytes().await.map_err(|e| {
AsxError::new(
ErrorCode::TransportFailure,
format!("SMP response body read failed: {e}"),
ErrorContext::new("smp_lookup_body"),
)
})?;
let transport_profile = req
.transport_profile
.as_deref()
.unwrap_or(&self.config.transport_profile);
parse_service_metadata(
&body,
&req.process_id,
transport_profile,
&self.config.signature_policy,
)
}
pub fn build_lookup_url(&self, req: &SmpLookupRequest) -> String {
let smp_base = self.build_smp_base_url(&req.participant_id);
let canonical = format!("{}::{}", self.config.participant_scheme, req.participant_id);
format!(
"{}{}/services/{}",
smp_base,
percent_encode(&canonical),
percent_encode(&req.document_type_id),
)
}
fn build_smp_base_url(&self, participant_id: &str) -> String {
let canonical = format!(
"{}::{}",
self.config.participant_scheme,
participant_id.to_lowercase()
);
let hash = md5_hex(canonical.as_bytes());
format!("https://B-{}.{}/", hash, self.config.sml_zone)
}
}
const SMP_NS: &str = "http://busdox.org/serviceMetadata/publishing/1.0/";
const SMP_NS_V2: &str = "http://docs.oasis-open.org/bdxr/ns/SMP/2/ServiceMetadata";
fn parse_service_metadata(
xml: &[u8],
process_id: &str,
transport_profile: &str,
signature_policy: &SmpSignaturePolicy,
) -> Result<SmpEndpoint> {
let text = std::str::from_utf8(xml).map_err(|_| {
AsxError::new(
ErrorCode::ParseFailed,
"SMP ServiceMetadata response is not valid UTF-8",
ErrorContext::new("smp_parse"),
)
})?;
let doc = Document::parse(text).map_err(|e| {
AsxError::new(
ErrorCode::ParseFailed,
format!("SMP ServiceMetadata XML parse failed: {e}"),
ErrorContext::new("smp_parse"),
)
})?;
let signer = enforce_smp_signature_policy(text, &doc, signature_policy)?;
let mut endpoint = extract_endpoint(&doc, process_id, transport_profile).ok_or_else(|| {
AsxError::new(
ErrorCode::NotFound,
format!(
"no matching AS4 endpoint found in SMP for process '{process_id}' \
with transport profile '{transport_profile}'"
),
ErrorContext::new("smp_parse"),
)
})?;
endpoint.signed_document = std::sync::Arc::from(xml);
endpoint.verified_signer_fingerprint_sha256 = signer;
Ok(endpoint)
}
fn enforce_smp_signature_policy(
text: &str,
doc: &Document<'_>,
policy: &SmpSignaturePolicy,
) -> Result<Option<String>> {
match policy {
SmpSignaturePolicy::Deny => Err(AsxError::new(
ErrorCode::PolicyViolation,
"SMP lookup results are not authorized for use: SmpConfig::signature_policy is \
SmpSignaturePolicy::Deny (the default). Set \
SmpSignaturePolicy::verify_with_trust_anchors(smp_ca_pems) to verify the \
response against the network's SMP CA, or one of the weaker variants for a \
closed or test network",
ErrorContext::new("smp_signature_policy"),
)),
SmpSignaturePolicy::Verify(revocation) => {
let verified = crate::crypto::wssec::verify_enveloped_document_signature(
text,
None,
&crate::crypto::wssec::RevocationPolicy::from(revocation.as_ref()),
)
.map_err(|err| {
AsxError::new(
ErrorCode::SecurityVerificationFailed,
format!(
"SMP ServiceMetadata signature verification failed: {}. The endpoint \
URL and recipient certificate in this response cannot be trusted",
err.message
),
ErrorContext::new("smp_verify_signature"),
)
})?;
Ok(Some(verified.signer_fingerprint_sha256))
}
SmpSignaturePolicy::RequireSignaturePresent => {
if !has_enveloped_signature(doc) {
return Err(AsxError::new(
ErrorCode::SecurityVerificationFailed,
"SMP ServiceMetadata response carries no ds:Signature; PEPPOL and CEF \
eDelivery require the SMP to sign its metadata. Set \
SmpConfig::signature_policy = AllowUnsigned only for a closed or test \
network",
ErrorContext::new("smp_parse_signature"),
));
}
Ok(None)
}
SmpSignaturePolicy::AllowUnsigned => Ok(None),
}
}
const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#";
fn has_enveloped_signature(doc: &Document<'_>) -> bool {
doc.descendants().any(|n| {
n.is_element()
&& n.tag_name().namespace() == Some(XMLDSIG_NS)
&& n.tag_name().name() == "Signature"
})
}
fn extract_endpoint(
doc: &Document<'_>,
process_id: &str,
transport_profile: &str,
) -> Option<SmpEndpoint> {
for node in doc.descendants() {
if !matches_smp_element(&node, "Endpoint") {
continue;
}
let profile = node.attribute("transportProfile")?;
if !profile.eq_ignore_ascii_case(transport_profile) {
continue;
}
let process_node = find_ancestor_process_id(doc, &node)?;
if !process_node.eq_ignore_ascii_case(process_id) {
continue;
}
let url =
find_child_text(&node, "EndpointURI").or_else(|| find_child_text(&node, "Address"))?;
let certificate_der_b64 = find_child_text(&node, "Certificate");
let service_description = find_child_text(&node, "ServiceDescription");
let service_activation_date = find_child_text(&node, "ServiceActivationDate");
let service_expiration_date = find_child_text(&node, "ServiceExpirationDate");
return Some(SmpEndpoint {
signed_document: std::sync::Arc::from(&[][..]),
verified_signer_fingerprint_sha256: None,
url: url.trim().to_string(),
certificate_der_b64: certificate_der_b64.map(|s| s.trim().to_string()),
transport_profile: profile.to_string(),
service_description: service_description.map(|s| s.trim().to_string()),
service_activation_date: service_activation_date.map(|s| s.trim().to_string()),
service_expiration_date: service_expiration_date.map(|s| s.trim().to_string()),
});
}
None
}
fn matches_smp_element(node: &roxmltree::Node<'_, '_>, local: &str) -> bool {
if !node.is_element() {
return false;
}
if node.tag_name().name() != local {
return false;
}
let ns = node.tag_name().namespace().unwrap_or("");
ns.is_empty() || ns == SMP_NS || ns == SMP_NS_V2
}
fn find_ancestor_process_id<'a>(
_doc: &'a Document<'a>,
endpoint: &roxmltree::Node<'a, '_>,
) -> Option<&'a str> {
let service_endpoint_list = endpoint.parent()?;
let process = service_endpoint_list.parent()?;
for child in process.children() {
if matches_smp_element(&child, "ProcessIdentifier") {
return child.text();
}
}
None
}
fn find_child_text<'a>(node: &roxmltree::Node<'a, '_>, name: &str) -> Option<&'a str> {
for child in node.children() {
if matches_smp_element(&child, name) {
return child.text();
}
}
None
}
fn md5_hex(input: &[u8]) -> String {
use openssl::hash::{MessageDigest, hash};
let digest = hash(MessageDigest::md5(), input).expect("MD5 unavailable");
let mut hex = String::with_capacity(32);
for b in &*digest {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
fn percent_encode(s: &str) -> String {
let mut encoded = String::with_capacity(s.len() * 3);
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
encoded.push(b as char);
} else {
use std::fmt::Write;
let _ = write!(encoded, "%{b:02X}");
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn md5_hex_known_value() {
let result = md5_hex("iso6523-actorid-upis::0088:5798009883995".as_bytes());
assert_eq!(result.len(), 32, "MD5 hex should be 32 chars");
assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn percent_encode_peppol_doc_type() {
let raw = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##test";
let encoded = percent_encode(raw);
assert!(!encoded.contains(':'), "colons must be encoded");
assert!(!encoded.contains('#'), "hash must be encoded");
assert!(encoded.contains("urn%3Aoasis"), "colon should be %3A");
}
#[test]
fn build_lookup_url_structure() {
let client = SmpClient::new("acc.edelivery.tech.ec.europa.eu");
let req = SmpLookupRequest {
participant_id: "0088:5798009883995".to_string(),
document_type_id: "urn:test:doc".to_string(),
process_id: "urn:test:process".to_string(),
transport_profile: None,
};
let url = client.build_lookup_url(&req);
assert!(
url.starts_with("https://B-"),
"must start with SMP DNS scheme"
);
assert!(
url.contains(".acc.edelivery.tech.ec.europa.eu/"),
"must embed SML zone"
);
assert!(url.contains("/services/"), "must have /services/ path");
}
#[test]
fn parse_service_metadata_smp1_roundtrip() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<ServiceMetadata xmlns="http://busdox.org/serviceMetadata/publishing/1.0/">
<ServiceInformation>
<ParticipantIdentifier scheme="iso6523-actorid-upis">0088:1234567890123</ParticipantIdentifier>
<DocumentIdentifier scheme="busdox-docid-qns">urn:test:doc</DocumentIdentifier>
<ProcessList>
<Process>
<ProcessIdentifier scheme="cenbii-procid-ubl">urn:test:process</ProcessIdentifier>
<ServiceEndpointList>
<Endpoint transportProfile="peppol-transport-as4-v2_0">
<EndpointURI>https://ap.example.com/as4/receive</EndpointURI>
<Certificate>MIIB…</Certificate>
<ServiceDescription>Test AP</ServiceDescription>
<ServiceActivationDate>2024-01-01</ServiceActivationDate>
<ServiceExpirationDate>2025-12-31</ServiceExpirationDate>
</Endpoint>
</ServiceEndpointList>
</Process>
</ProcessList>
</ServiceInformation>
</ServiceMetadata>"#;
let ep = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::AllowUnsigned,
)
.expect("should parse");
assert_eq!(ep.url, "https://ap.example.com/as4/receive");
assert_eq!(ep.transport_profile, "peppol-transport-as4-v2_0");
assert_eq!(ep.service_description.as_deref(), Some("Test AP"));
assert_eq!(ep.service_activation_date.as_deref(), Some("2024-01-01"));
}
#[test]
fn parse_service_metadata_no_match_returns_not_found() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<ServiceMetadata xmlns="http://busdox.org/serviceMetadata/publishing/1.0/">
<ServiceInformation>
<ProcessList>
<Process>
<ProcessIdentifier scheme="x">urn:other:process</ProcessIdentifier>
<ServiceEndpointList>
<Endpoint transportProfile="peppol-transport-as4-v2_0">
<EndpointURI>https://ap.example.com/as4/receive</EndpointURI>
</Endpoint>
</ServiceEndpointList>
</Process>
</ProcessList>
</ServiceInformation>
</ServiceMetadata>"#;
let err = parse_service_metadata(
xml.as_bytes(),
"urn:test:process", "peppol-transport-as4-v2_0",
&SmpSignaturePolicy::AllowUnsigned,
)
.unwrap_err();
assert_eq!(err.code, crate::core::ErrorCode::NotFound);
}
#[test]
fn unsigned_service_metadata_is_rejected_by_the_presence_gate() {
let xml = SIGNED_FIXTURE_TEMPLATE.replace("{signature}", "");
let err = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::RequireSignaturePresent,
)
.expect_err("unsigned response must be rejected by the presence gate");
assert_eq!(err.code, crate::core::ErrorCode::SecurityVerificationFailed);
assert!(err.message.contains("ds:Signature"), "{}", err.message);
}
#[test]
fn lookup_results_are_denied_until_a_policy_is_chosen() {
let signature = r#"<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignatureValue>AAAA</ds:SignatureValue></ds:Signature>"#;
let xml = SIGNED_FIXTURE_TEMPLATE.replace("{signature}", signature);
let err = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::default(),
)
.expect_err("the default policy must refuse to hand back a routing decision");
assert_eq!(err.code, crate::core::ErrorCode::PolicyViolation);
assert!(
err.message.contains("SmpSignaturePolicy"),
"the error must name the knob to set: {}",
err.message
);
assert!(matches!(
SmpConfig::peppol_production().signature_policy,
SmpSignaturePolicy::Deny
));
}
#[test]
fn signed_service_metadata_is_accepted_and_bytes_are_retained() {
let signature = r#"<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignatureValue>AAAA</ds:SignatureValue></ds:Signature>"#;
let xml = SIGNED_FIXTURE_TEMPLATE.replace("{signature}", signature);
let ep = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::RequireSignaturePresent,
)
.expect("a signed response passes the presence gate");
assert_eq!(ep.signed_document.as_ref(), xml.as_bytes());
}
#[test]
fn signature_presence_gate_can_be_disabled_for_closed_networks() {
let xml = SIGNED_FIXTURE_TEMPLATE.replace("{signature}", "");
parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::AllowUnsigned,
)
.expect("closed networks may opt out");
}
const SIGNED_FIXTURE_TEMPLATE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<ServiceMetadata xmlns="http://busdox.org/serviceMetadata/publishing/1.0/">
<ServiceInformation>
<ProcessList>
<Process>
<ProcessIdentifier scheme="cenbii-procid-ubl">urn:test:process</ProcessIdentifier>
<ServiceEndpointList>
<Endpoint transportProfile="peppol-transport-as4-v2_0">
<EndpointURI>https://ap.example.com/as4/receive</EndpointURI>
</Endpoint>
</ServiceEndpointList>
</Process>
</ProcessList>
</ServiceInformation>
{signature}
</ServiceMetadata>"#;
}