use roxmltree::Document;
use super::super::services::wssec_chain_policy_from_session;
use super::super::types::As4PushPolicy;
use crate::core::{AsxError, ErrorCode, ErrorContext, Result, SessionContext};
use crate::crypto::wssec::WsSecVerifyOptions;
pub(crate) mod private {
pub(crate) trait Sealed {}
}
#[non_exhaustive]
pub(crate) struct As4SecurityVerification<'a> {
pub session: &'a SessionContext,
pub policy: &'a As4PushPolicy,
pub soap_xml: &'a str,
pub soap_doc: &'a Document<'a>,
pub message_id: &'a str,
pub external_references: &'a [(&'a str, &'a [u8])],
pub from_party_id: &'a str,
pub permitted_signers: &'a [crate::crypto::CertFingerprint],
pub(crate) signer_check: Option<&'a crate::crypto::wssec::verify::SignerCheck>,
pub(crate) evidence:
&'a std::sync::OnceLock<crate::crypto::wssec::verify::VerifiedSignatureCoverage>,
}
impl std::fmt::Debug for As4SecurityVerification<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("As4SecurityVerification")
.field("message_id", &self.message_id)
.field("from_party_id", &self.from_party_id)
.field("permitted_signers", &self.permitted_signers)
.field("signer_check", &self.signer_check.is_some())
.finish_non_exhaustive()
}
}
pub(crate) trait As4Verifier: private::Sealed {
fn verify_security(&self, ctx: As4SecurityVerification<'_>) -> Result<()>;
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct As4WsSecVerifier;
impl private::Sealed for As4WsSecVerifier {}
impl As4Verifier for As4WsSecVerifier {
fn verify_security(&self, ctx: As4SecurityVerification<'_>) -> Result<()> {
let As4SecurityVerification {
session,
policy,
soap_xml,
soap_doc,
message_id,
external_references,
from_party_id,
permitted_signers,
signer_check,
evidence,
} = ctx;
let chain_policy = wssec_chain_policy_from_session(session)?;
let opts = WsSecVerifyOptions::new()
.with_expected_fingerprints(permitted_signers)
.with_signer_check(signer_check)
.with_chain_policy(chain_policy)
.at(session.clock().now())
.deferring_attachments();
let coverage = crate::crypto::wssec::verify::verify_enveloped_signature_optional_with_doc(
soap_doc,
soap_xml,
crate::crypto::wssec::verify::SignedDocClass::UserMessage,
opts,
)?;
let signature_present = coverage.is_some();
if policy.require_signed_push && !signature_present {
return Err(AsxError::new(
ErrorCode::SecurityVerificationFailed,
"AS4 push message signature is required but not present",
ErrorContext::for_session_with_message("as4_receive_push", session, message_id),
));
}
if signature_present && permitted_signers.is_empty() && signer_check.is_none() {
return Err(AsxError::new(
ErrorCode::PolicyViolation,
format!(
"no signing certificate is pinned for the claimed sender '{from_party_id}', \
so a valid signature could not be attributed to it; pin the partner's \
signing certificate (Partner::signing_cert)"
),
ErrorContext::for_session("as4_receive_push", session),
));
}
if let Some(coverage) = coverage {
enforce_messaging_signature_coverage(session, soap_doc, message_id, &coverage)?;
enforce_attachment_signature_coverage(
session,
message_id,
external_references,
&coverage,
)?;
let _ = evidence.set(coverage);
}
Ok(())
}
}
pub(super) fn enforce_attachment_signature_coverage(
session: &SessionContext,
message_id: &str,
attachments: &[(&str, &[u8])],
coverage: &crate::crypto::wssec::verify::VerifiedSignatureCoverage,
) -> Result<()> {
for (cid, _) in attachments {
let cid = normalize_cid(cid);
if coverage
.signed_cid_references
.iter()
.any(|signed| normalize_cid(signed) == cid)
{
continue;
}
return Err(AsxError::new(
ErrorCode::SecurityVerificationFailed,
format!(
"AS4 payload attachment cid:{cid} is not covered by the WS-Security \
signature; the signature must include a ds:Reference URI=\"cid:{cid}\" \
so the payload is integrity-protected alongside the eb:Messaging header"
),
ErrorContext::for_session_with_message("as4_receive_push", session, message_id),
));
}
Ok(())
}
fn normalize_cid(value: &str) -> &str {
let value = value.trim();
let value = value.strip_prefix('<').unwrap_or(value);
let value = value.strip_suffix('>').unwrap_or(value);
value
.get(..4)
.filter(|prefix| prefix.eq_ignore_ascii_case("cid:"))
.map_or(value, |_| &value[4..])
}
const EBMS3_NS: &str = "http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/";
const WSU_NS: &str =
"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd";
fn enforce_messaging_signature_coverage(
session: &SessionContext,
soap_doc: &Document<'_>,
message_id: &str,
coverage: &crate::crypto::wssec::verify::VerifiedSignatureCoverage,
) -> Result<()> {
let messaging_nodes: Vec<_> = soap_doc
.descendants()
.filter(|n| {
n.is_element()
&& n.tag_name().name() == "Messaging"
&& n.tag_name().namespace() == Some(EBMS3_NS)
})
.collect();
if messaging_nodes.len() != 1 {
return Err(AsxError::new(
ErrorCode::SecurityVerificationFailed,
format!(
"AS4 envelope must contain exactly one eb:Messaging header block (found {})",
messaging_nodes.len()
),
ErrorContext::for_session_with_message("as4_receive_push", session, message_id),
));
}
let messaging = messaging_nodes[0];
let messaging_id = messaging
.attribute((WSU_NS, "Id"))
.or_else(|| messaging.attribute("Id"));
let covered =
messaging_id.is_some_and(|id| coverage.signed_same_document_ids.iter().any(|s| s == id));
if !covered {
return Err(AsxError::new(
ErrorCode::SecurityVerificationFailed,
"AS4 eb:Messaging header block is not covered by the verified WS-Security signature \
(possible XML signature wrapping)",
ErrorContext::for_session_with_message("as4_receive_push", session, message_id),
));
}
Ok(())
}
#[cfg(test)]
mod xsw_coverage_tests {
use super::{
EBMS3_NS, ErrorCode, enforce_attachment_signature_coverage,
enforce_messaging_signature_coverage,
};
use crate::core::SessionContext;
use crate::crypto::wssec::verify::VerifiedSignatureCoverage;
use roxmltree::Document;
fn session() -> SessionContext {
SessionContext::new("s-xsw", "partner", "strict").expect("session")
}
fn coverage(ids: &[&str]) -> VerifiedSignatureCoverage {
VerifiedSignatureCoverage {
signed_same_document_ids: ids.iter().map(|s| s.to_string()).collect(),
signed_cid_references: Vec::new(),
signer_certificate_der: None,
references: Vec::new(),
deferred: None,
signer_path: Vec::new(),
}
}
fn cid_coverage(cids: &[&str]) -> VerifiedSignatureCoverage {
VerifiedSignatureCoverage {
signed_same_document_ids: Vec::new(),
signed_cid_references: cids.iter().map(|s| s.to_string()).collect(),
signer_certificate_der: None,
references: Vec::new(),
deferred: None,
signer_path: Vec::new(),
}
}
#[test]
fn attachment_covered_by_a_signed_cid_reference_is_accepted() {
enforce_attachment_signature_coverage(
&session(),
"m1",
&[("payload@example.com", b"bytes".as_slice())],
&cid_coverage(&["payload@example.com"]),
)
.expect("covered attachment must verify");
}
#[test]
fn cid_comparison_ignores_scheme_prefix_and_angle_brackets() {
for (attachment, signed) in [
("cid:p@e.com", "p@e.com"),
("p@e.com", "cid:p@e.com"),
("<p@e.com>", "p@e.com"),
("CID:p@e.com", "p@e.com"),
] {
enforce_attachment_signature_coverage(
&session(),
"m1",
&[(attachment, b"bytes".as_slice())],
&cid_coverage(&[signed]),
)
.unwrap_or_else(|err| panic!("{attachment} vs {signed} must match: {err}"));
}
}
#[test]
fn attachment_not_covered_by_any_signed_reference_is_rejected() {
let err = enforce_attachment_signature_coverage(
&session(),
"m1",
&[("payload@example.com", b"bytes".as_slice())],
&coverage(&["as4-messaging"]),
)
.expect_err("uncovered attachment must be rejected");
assert_eq!(err.code, ErrorCode::SecurityVerificationFailed);
assert!(err.message.contains("payload@example.com"));
}
#[test]
fn attachment_covered_by_a_different_cid_is_rejected() {
let err = enforce_attachment_signature_coverage(
&session(),
"m1",
&[("payload@example.com", b"bytes".as_slice())],
&cid_coverage(&["decoy@example.com"]),
)
.expect_err("a signed reference to a different part must not count");
assert_eq!(err.code, ErrorCode::SecurityVerificationFailed);
}
#[test]
fn no_attachment_needs_no_cid_coverage() {
enforce_attachment_signature_coverage(&session(), "m1", &[], &coverage(&["as4-messaging"]))
.expect("a message with no attachment has nothing to cover");
}
#[test]
fn every_attachment_must_be_covered_not_just_the_first() {
let attachments: &[(&str, &[u8])] =
&[("xmlpayload@gitb", b"one"), ("custompayload@gitb", b"two")];
enforce_attachment_signature_coverage(
&session(),
"m1",
attachments,
&cid_coverage(&["xmlpayload@gitb", "custompayload@gitb"]),
)
.expect("both covered");
let err = enforce_attachment_signature_coverage(
&session(),
"m1",
attachments,
&cid_coverage(&["xmlpayload@gitb"]),
)
.expect_err("the second attachment is not covered");
assert_eq!(err.code, ErrorCode::SecurityVerificationFailed);
assert!(
err.message.contains("custompayload@gitb"),
"{}",
err.message
);
}
fn envelope_with(messaging_blocks: &str) -> String {
format!(
r#"<S12:Envelope xmlns:S12="http://www.w3.org/2003/05/soap-envelope"
xmlns:eb="{EBMS3_NS}"
xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<S12:Header>{messaging_blocks}</S12:Header>
<S12:Body/>
</S12:Envelope>"#
)
}
#[test]
fn accepts_single_signed_messaging() {
let xml = envelope_with(
r#"<eb:Messaging wsu:Id="as4-messaging"><eb:UserMessage/></eb:Messaging>"#,
);
let doc = Document::parse(&xml).unwrap();
enforce_messaging_signature_coverage(&session(), &doc, "m1", &coverage(&["as4-messaging"]))
.expect("signed eb:Messaging must be accepted");
}
#[test]
fn rejects_uncovered_messaging_id() {
let xml =
envelope_with(r#"<eb:Messaging wsu:Id="attacker-id"><eb:UserMessage/></eb:Messaging>"#);
let doc = Document::parse(&xml).unwrap();
let err = enforce_messaging_signature_coverage(
&session(),
&doc,
"m1",
&coverage(&["as4-messaging"]),
)
.expect_err("eb:Messaging id not in signed set must reject");
assert_eq!(err.code, crate::core::ErrorCode::SecurityVerificationFailed);
}
#[test]
fn rejects_injected_second_messaging_block() {
let xml = envelope_with(
r#"<eb:Messaging wsu:Id="as4-messaging"><eb:UserMessage/></eb:Messaging>
<eb:Messaging><eb:UserMessage/></eb:Messaging>"#,
);
let doc = Document::parse(&xml).unwrap();
let err = enforce_messaging_signature_coverage(
&session(),
&doc,
"m1",
&coverage(&["as4-messaging"]),
)
.expect_err("two eb:Messaging blocks must reject");
assert_eq!(err.code, crate::core::ErrorCode::SecurityVerificationFailed);
}
#[test]
fn rejects_messaging_without_wsu_id() {
let xml = envelope_with(r#"<eb:Messaging><eb:UserMessage/></eb:Messaging>"#);
let doc = Document::parse(&xml).unwrap();
let err = enforce_messaging_signature_coverage(
&session(),
&doc,
"m1",
&coverage(&["as4-messaging"]),
)
.expect_err("unsigned eb:Messaging must reject");
assert_eq!(err.code, crate::core::ErrorCode::SecurityVerificationFailed);
}
}