use super::pmode::PayloadPackagingMode;
use crate::core::InteropMode;
use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
use crate::crypto::soap_builder::WsAddressingHeaders;
use crate::crypto::wssec::{
WsSecOutboundKeyInfoProfile, WsSecSignatureReference, XmlEncPayloadAlgorithm,
};
use crate::interop::InteropExceptionPolicy;
use crate::lifecycle::DomainReady;
use crate::reliability::{DeliveryOutcome, RetryDecision};
use crate::sbdh::SbdhHeader;
use std::sync::Arc;
use zeroize::Zeroize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SoapEnvelope {
pub action: String,
pub body: Arc<[u8]>,
}
impl SoapEnvelope {
#[inline]
pub fn body_bytes(&self) -> bytes::Bytes {
bytes::Bytes::copy_from_slice(&self.body)
}
#[inline]
pub fn into_body_bytes(self) -> bytes::Bytes {
bytes::Bytes::from(self.body.to_vec())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum FragmentScopePolicy {
#[default]
RequireAuthenticatedScope,
UseSoapSenderId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4PushPolicy {
pub interop: InteropMode,
pub interop_exceptions: InteropExceptionPolicy,
pub require_signed_receipt: bool,
pub require_signed_push: bool,
pub fail_closed_audit_events: bool,
pub inbound_decryption_key_pem: Option<Arc<[u8]>>,
pub require_encrypted_inbound: bool,
pub timestamp_freshness_window: Option<std::time::Duration>,
pub fragment_scope_policy: FragmentScopePolicy,
}
impl Default for As4PushPolicy {
fn default() -> Self {
Self {
interop: InteropMode::Strict,
interop_exceptions: InteropExceptionPolicy::default(),
require_signed_receipt: true,
require_signed_push: true,
fail_closed_audit_events: true,
inbound_decryption_key_pem: None,
require_encrypted_inbound: false,
timestamp_freshness_window: Some(std::time::Duration::from_secs(300)),
fragment_scope_policy: FragmentScopePolicy::RequireAuthenticatedScope,
}
}
}
impl As4PushPolicy {
pub fn strict() -> Self {
Self::default()
}
pub fn regulated() -> Self {
Self {
interop: InteropMode::Strict,
interop_exceptions: InteropExceptionPolicy::default(),
require_signed_receipt: true,
require_signed_push: true,
fail_closed_audit_events: true,
inbound_decryption_key_pem: None,
require_encrypted_inbound: false,
timestamp_freshness_window: Some(std::time::Duration::from_secs(300)),
fragment_scope_policy: FragmentScopePolicy::RequireAuthenticatedScope,
}
}
pub fn regulated_with_decryption_key(pem: impl Into<Vec<u8>>) -> Self {
Self {
inbound_decryption_key_pem: Some(Arc::from(pem.into())),
require_encrypted_inbound: true,
..Self::regulated()
}
}
#[cfg(all(feature = "testing", feature = "interop-relaxed"))]
pub fn test_relaxed() -> Self {
Self {
interop: InteropMode::Relaxed,
require_signed_push: false,
require_signed_receipt: false,
fail_closed_audit_events: false,
timestamp_freshness_window: None,
fragment_scope_policy: FragmentScopePolicy::UseSoapSenderId,
..Self::default()
}
}
}
fn validate_strict_as4_policy_consistency(
stage: &'static str,
interop: InteropMode,
interop_exceptions: &InteropExceptionPolicy,
) -> Result<()> {
if interop == InteropMode::Strict
&& (interop_exceptions.scoped_profile_name.is_some()
|| !interop_exceptions.allowed.is_empty())
{
return Err(AsxError::new(
ErrorCode::InvalidInput,
"strict AS4 policy forbids configured interop exception overrides",
ErrorContext::new(stage),
));
}
Ok(())
}
fn validate_strict_as4_send_policy_consistency(
stage: &'static str,
interop: InteropMode,
sign: bool,
fail_closed_audit_events: bool,
payload_packaging_mode: PayloadPackagingMode,
) -> Result<()> {
if interop == InteropMode::Strict {
if !sign {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"strict AS4 send policy forbids sign=false",
ErrorContext::new(stage),
));
}
if payload_packaging_mode != PayloadPackagingMode::MimeAttachment {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"strict AS4 send policy requires MIME attachment payload packaging",
ErrorContext::new(stage),
));
}
}
#[cfg(not(feature = "testing"))]
if interop == InteropMode::Strict && !fail_closed_audit_events {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"strict AS4 send policy requires fail_closed_audit_events=true in non-testing builds",
ErrorContext::new(stage),
));
}
#[cfg(feature = "testing")]
let _ = fail_closed_audit_events;
Ok(())
}
pub(crate) fn validate_as4_send_policy_and_credentials_consistency(
stage: &'static str,
policy: &As4SendPolicy,
credentials: &As4SendCredentials,
error_code: ErrorCode,
) -> Result<()> {
let ctx = || ErrorContext::new(stage);
if policy.action.trim().is_empty() {
return Err(AsxError::new(
error_code,
"As4SendPolicy.action must not be empty",
ctx(),
));
}
if policy.service.trim().is_empty() {
return Err(AsxError::new(
error_code,
"As4SendPolicy.service must not be empty",
ctx(),
));
}
if let Some(ref id) = policy.ref_to_message_id
&& id.trim().is_empty()
{
return Err(AsxError::new(
error_code,
"As4SendPolicy.ref_to_message_id must not be empty when set",
ctx(),
));
}
if let Some(ref conversation_id) = policy.conversation_id
&& conversation_id.trim().is_empty()
{
return Err(AsxError::new(
error_code,
"As4SendPolicy.conversation_id must not be empty when set",
ctx(),
));
}
if policy.sign {
if credentials.signing_cert_pem.is_none() {
return Err(AsxError::new(
error_code,
"sign = true requires signing_cert_pem",
ctx(),
));
}
if credentials.signing_key_pem.is_none() {
return Err(AsxError::new(
error_code,
"sign = true requires signing_key_pem",
ctx(),
));
}
}
if (policy.encrypt || policy.encrypt_soap_headers) && credentials.recipient_cert_pem.is_none() {
return Err(AsxError::new(
error_code,
"encrypt = true or encrypt_soap_headers = true requires recipient_cert_pem",
ctx(),
));
}
Ok(())
}
#[cfg(feature = "as4")]
#[derive(Debug, Clone)]
pub struct As4PreparedSendCredentials {
pub signing_cert: Option<openssl::x509::X509>,
pub signing_key: Option<openssl::pkey::PKey<openssl::pkey::Private>>,
pub recipient_cert: Option<openssl::x509::X509>,
}
impl As4SendCredentials {
#[cfg(feature = "as4")]
pub fn prepare_for_policy(
&self,
policy: &As4SendPolicy,
stage: &'static str,
error_code: ErrorCode,
) -> Result<As4PreparedSendCredentials> {
let ctx = || ErrorContext::new(stage);
let mut prepared = As4PreparedSendCredentials {
signing_cert: None,
signing_key: None,
recipient_cert: None,
};
if policy.sign {
let cert_pem = self.signing_cert_pem.as_ref().ok_or_else(|| {
AsxError::new(error_code, "sign = true requires signing_cert_pem", ctx())
})?;
let key_pem = self.signing_key_pem.as_ref().ok_or_else(|| {
AsxError::new(error_code, "sign = true requires signing_key_pem", ctx())
})?;
let signing_cert = openssl::x509::X509::from_pem(cert_pem).map_err(|_err| {
AsxError::new(
error_code,
"signing_cert_pem is not a valid PEM X.509 certificate",
ctx(),
)
})?;
let signing_key = openssl::pkey::PKey::private_key_from_pem(key_pem).map_err(|_err| {
AsxError::new(
error_code,
"signing_key_pem is not a valid PEM private key (check PEM format and key type)",
ctx(),
)
})?;
let signing_cert_public = signing_cert.public_key().map_err(|_err| {
AsxError::new(
error_code,
"signing_cert_pem does not contain a usable public key",
ctx(),
)
})?;
if !signing_key.public_eq(&signing_cert_public) {
return Err(AsxError::new(
error_code,
"signing_cert_pem does not match signing_key_pem",
ctx(),
));
}
prepared.signing_cert = Some(signing_cert);
prepared.signing_key = Some(signing_key);
}
if policy.encrypt {
let cert_pem = self.recipient_cert_pem.as_ref().ok_or_else(|| {
AsxError::new(
error_code,
"encrypt = true requires recipient_cert_pem",
ctx(),
)
})?;
let recipient_cert = openssl::x509::X509::from_pem(cert_pem).map_err(|_err| {
AsxError::new(
error_code,
"recipient_cert_pem is not a valid PEM X.509 certificate",
ctx(),
)
})?;
prepared.recipient_cert = Some(recipient_cert);
} else if let Some(cert_pem) = &self.recipient_cert_pem {
let recipient_cert = openssl::x509::X509::from_pem(cert_pem).map_err(|_err| {
AsxError::new(
error_code,
"recipient_cert_pem is not a valid PEM X.509 certificate",
ctx(),
)
})?;
prepared.recipient_cert = Some(recipient_cert);
}
Ok(prepared)
}
}
fn validate_strict_as4_receive_policy_consistency(
stage: &'static str,
interop: InteropMode,
require_signed_receipt: bool,
fail_closed_audit_events: bool,
) -> Result<()> {
if interop == InteropMode::Strict && !require_signed_receipt {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"strict AS4 receive policy requires require_signed_receipt=true",
ErrorContext::new(stage),
));
}
#[cfg(not(feature = "testing"))]
if interop == InteropMode::Strict && !fail_closed_audit_events {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"strict AS4 receive policy requires fail_closed_audit_events=true in non-testing builds",
ErrorContext::new(stage),
));
}
#[cfg(feature = "testing")]
let _ = fail_closed_audit_events;
Ok(())
}
#[derive(Debug, Default)]
pub struct As4PushPolicyBuilder(As4PushPolicy);
impl As4PushPolicyBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn regulated() -> Self {
Self(As4PushPolicy::regulated())
}
pub fn interop(mut self, mode: InteropMode) -> Self {
self.0.interop = mode;
self
}
pub fn interop_exceptions(mut self, exc: InteropExceptionPolicy) -> Self {
self.0.interop_exceptions = exc;
self
}
pub fn require_signed_receipt(mut self, v: bool) -> Self {
self.0.require_signed_receipt = v;
self
}
#[cfg(feature = "testing")]
pub fn allow_unsigned_push(mut self, allow: bool) -> Self {
self.0.require_signed_push = !allow;
self
}
pub fn fail_closed_audit_events(mut self, v: bool) -> Self {
self.0.fail_closed_audit_events = v;
self
}
pub fn inbound_decryption_key_pem(mut self, pem: Vec<u8>) -> Self {
self.0.inbound_decryption_key_pem = Some(Arc::from(pem));
self
}
pub fn require_encrypted_inbound(mut self, require: bool) -> Self {
self.0.require_encrypted_inbound = require;
self
}
pub fn with_mandatory_inbound_encryption(mut self, pem: Vec<u8>) -> Self {
self.0.inbound_decryption_key_pem = Some(Arc::from(pem));
self.0.require_encrypted_inbound = true;
self
}
pub fn timestamp_freshness_window(mut self, window: Option<std::time::Duration>) -> Self {
self.0.timestamp_freshness_window = window;
self
}
pub fn fragment_scope_policy(mut self, policy: FragmentScopePolicy) -> Self {
self.0.fragment_scope_policy = policy;
self
}
pub fn build(self) -> Result<As4PushPolicy> {
let stage = "as4_push_policy_build";
validate_strict_as4_policy_consistency(stage, self.0.interop, &self.0.interop_exceptions)?;
validate_strict_as4_receive_policy_consistency(
stage,
self.0.interop,
self.0.require_signed_receipt,
self.0.fail_closed_audit_events,
)?;
if self.0.require_encrypted_inbound && self.0.inbound_decryption_key_pem.is_none() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"require_encrypted_inbound = true requires inbound_decryption_key_pem to be set",
ErrorContext::new(stage),
));
}
if let Some(ref pem) = self.0.inbound_decryption_key_pem {
openssl::pkey::PKey::private_key_from_pem(pem.as_ref()).map_err(|_err| {
AsxError::new(
ErrorCode::InvalidInput,
"inbound_decryption_key_pem is not a valid PEM private key (check PEM format and key type)",
ErrorContext::new(stage),
)
})?;
}
Ok(self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4SendPolicy {
pub interop: InteropMode,
pub outbound_key_info_profile: WsSecOutboundKeyInfoProfile,
pub fail_closed_audit_events: bool,
pub sign: bool,
pub encrypt: bool,
pub outbound_xmlenc_payload_algorithm: XmlEncPayloadAlgorithm,
pub encrypt_soap_headers: bool,
pub compress: bool,
pub action: String,
pub service: String,
pub service_type: String,
pub from_party_id: Option<String>,
pub to_party_id: Option<String>,
pub from_party_id_type: Option<String>,
pub to_party_id_type: Option<String>,
pub from_role: String,
pub to_role: String,
pub agreement_ref: Option<String>,
pub agreement_ref_type: Option<String>,
pub ref_to_message_id: Option<String>,
pub original_sender: Option<String>,
pub final_recipient: Option<String>,
pub tracking_identifier: Option<String>,
pub conversation_id: Option<String>,
pub ws_addressing: Option<WsAddressingHeaders>,
pub sbdh_header: Option<SbdhHeader>,
pub payload_packaging_mode: PayloadPackagingMode,
}
impl Default for As4SendPolicy {
fn default() -> Self {
Self {
interop: InteropMode::Strict,
outbound_key_info_profile: WsSecOutboundKeyInfoProfile::BinarySecurityTokenX509v3,
fail_closed_audit_events: true,
sign: true,
encrypt: false,
outbound_xmlenc_payload_algorithm: XmlEncPayloadAlgorithm::Aes128Gcm,
encrypt_soap_headers: false,
compress: false,
action: "urn:example:action".into(),
service: "http://example.org/example".into(),
service_type: "example".into(),
from_party_id: None,
to_party_id: None,
from_party_id_type: Some(
crate::crypto::soap_builder::EBCORE_PARTY_ID_TYPE_UNREGISTERED.into(),
),
to_party_id_type: Some(
crate::crypto::soap_builder::EBCORE_PARTY_ID_TYPE_UNREGISTERED.into(),
),
from_role: crate::crypto::soap_builder::EBMS_DEFAULT_ROLE.into(),
to_role: crate::crypto::soap_builder::EBMS_DEFAULT_ROLE.into(),
agreement_ref: None,
agreement_ref_type: None,
ref_to_message_id: None,
original_sender: None,
final_recipient: None,
tracking_identifier: None,
conversation_id: None,
ws_addressing: None,
sbdh_header: None,
payload_packaging_mode: PayloadPackagingMode::default(),
}
}
}
impl As4SendPolicy {
pub fn strict() -> Self {
Self::default()
}
pub fn regulated() -> Self {
Self {
interop: InteropMode::Strict,
outbound_key_info_profile: WsSecOutboundKeyInfoProfile::BinarySecurityTokenX509v3,
fail_closed_audit_events: true,
sign: true,
encrypt: false,
outbound_xmlenc_payload_algorithm: XmlEncPayloadAlgorithm::Aes128Gcm,
encrypt_soap_headers: false,
compress: false,
action: "urn:example:action".into(),
service: "http://example.org/example".into(),
service_type: "example".into(),
from_party_id: None,
to_party_id: None,
from_party_id_type: Some(
crate::crypto::soap_builder::EBCORE_PARTY_ID_TYPE_UNREGISTERED.into(),
),
to_party_id_type: Some(
crate::crypto::soap_builder::EBCORE_PARTY_ID_TYPE_UNREGISTERED.into(),
),
from_role: crate::crypto::soap_builder::EBMS_DEFAULT_ROLE.into(),
to_role: crate::crypto::soap_builder::EBMS_DEFAULT_ROLE.into(),
agreement_ref: None,
agreement_ref_type: None,
ref_to_message_id: None,
original_sender: None,
final_recipient: None,
tracking_identifier: None,
conversation_id: None,
ws_addressing: None,
sbdh_header: None,
payload_packaging_mode: PayloadPackagingMode::default(),
}
}
#[cfg(all(feature = "testing", feature = "interop-relaxed"))]
pub fn test_relaxed() -> Self {
Self {
interop: InteropMode::Relaxed,
fail_closed_audit_events: false,
sign: false,
..Self::default()
}
}
}
#[derive(Debug, Default)]
pub struct As4SendPolicyBuilder {
policy: As4SendPolicy,
credentials: As4SendCredentials,
}
impl As4SendPolicyBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn interop(mut self, mode: InteropMode) -> Self {
self.policy.interop = mode;
self
}
pub fn outbound_key_info_profile(mut self, profile: WsSecOutboundKeyInfoProfile) -> Self {
self.policy.outbound_key_info_profile = profile;
self
}
pub fn fail_closed_audit_events(mut self, v: bool) -> Self {
self.policy.fail_closed_audit_events = v;
self
}
pub fn payload_packaging_mode(mut self, mode: PayloadPackagingMode) -> Self {
self.policy.payload_packaging_mode = mode;
self
}
pub fn sign(mut self, v: bool) -> Self {
self.policy.sign = v;
self
}
pub fn encrypt(mut self, v: bool) -> Self {
self.policy.encrypt = v;
self
}
pub fn outbound_xmlenc_payload_algorithm(mut self, v: XmlEncPayloadAlgorithm) -> Self {
self.policy.outbound_xmlenc_payload_algorithm = v;
self
}
pub fn encrypt_soap_headers(mut self, v: bool) -> Self {
self.policy.encrypt_soap_headers = v;
self
}
pub fn compress(mut self, v: bool) -> Self {
self.policy.compress = v;
self
}
pub fn action(mut self, action: impl Into<String>) -> Self {
self.policy.action = action.into();
self
}
pub fn service(mut self, service: impl Into<String>, service_type: impl Into<String>) -> Self {
self.policy.service = service.into();
self.policy.service_type = service_type.into();
self
}
pub fn from_party(mut self, id: impl Into<String>) -> Self {
self.policy.from_party_id = Some(id.into());
self
}
pub fn to_party(mut self, id: impl Into<String>) -> Self {
self.policy.to_party_id = Some(id.into());
self
}
pub fn party_id_types(mut self, from_type: Option<String>, to_type: Option<String>) -> Self {
self.policy.from_party_id_type = from_type;
self.policy.to_party_id_type = to_type;
self
}
pub fn roles(mut self, from_role: impl Into<String>, to_role: impl Into<String>) -> Self {
self.policy.from_role = from_role.into();
self.policy.to_role = to_role.into();
self
}
pub fn agreement_ref(
mut self,
agreement: impl Into<String>,
agreement_type: Option<String>,
) -> Self {
self.policy.agreement_ref = Some(agreement.into());
self.policy.agreement_ref_type = agreement_type;
self
}
pub fn ref_to_message_id(mut self, id: impl Into<String>) -> Self {
self.policy.ref_to_message_id = Some(id.into());
self
}
pub fn original_sender(mut self, value: impl Into<String>) -> Self {
self.policy.original_sender = Some(value.into());
self
}
pub fn final_recipient(mut self, value: impl Into<String>) -> Self {
self.policy.final_recipient = Some(value.into());
self
}
pub fn tracking_identifier(mut self, value: impl Into<String>) -> Self {
self.policy.tracking_identifier = Some(value.into());
self
}
pub fn conversation_id(mut self, value: impl Into<String>) -> Self {
self.policy.conversation_id = Some(value.into());
self
}
pub fn sbdh_header(mut self, header: SbdhHeader) -> Self {
self.policy.sbdh_header = Some(header);
self
}
pub fn ws_addressing(mut self, headers: WsAddressingHeaders) -> Self {
self.policy.ws_addressing = Some(headers);
self
}
pub fn signing_cert_pem(mut self, pem: impl Into<Arc<[u8]>>) -> Self {
self.credentials.signing_cert_pem = Some(pem.into());
self
}
pub fn signing_key_pem(mut self, pem: Vec<u8>) -> Self {
self.credentials.signing_key_pem = Some(pem);
self
}
pub fn recipient_cert_pem(mut self, pem: impl Into<Arc<[u8]>>) -> Self {
self.credentials.recipient_cert_pem = Some(pem.into());
self
}
pub fn build(self) -> Result<(As4SendPolicy, As4SendCredentials)> {
let stage = "as4_send_policy_build";
validate_strict_as4_send_policy_consistency(
stage,
self.policy.interop,
self.policy.sign,
self.policy.fail_closed_audit_events,
self.policy.payload_packaging_mode,
)?;
validate_as4_send_policy_and_credentials_consistency(
stage,
&self.policy,
&self.credentials,
ErrorCode::InvalidInput,
)?;
#[cfg(feature = "as4")]
self.credentials
.prepare_for_policy(&self.policy, stage, ErrorCode::InvalidInput)?;
Ok((self.policy, self.credentials))
}
}
#[derive(Clone, Default)]
pub struct As4SendCredentials {
pub signing_cert_pem: Option<Arc<[u8]>>,
pub signing_key_pem: Option<Vec<u8>>,
pub recipient_cert_pem: Option<Arc<[u8]>>,
}
impl std::fmt::Debug for As4SendCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("As4SendCredentials")
.field("signing_cert_pem", &self.signing_cert_pem)
.field(
"signing_key_pem",
&crate::core::redact_present(self.signing_key_pem.is_some()),
)
.field("recipient_cert_pem", &self.recipient_cert_pem)
.finish()
}
}
impl Drop for As4SendCredentials {
fn drop(&mut self) {
if let Some(key) = self.signing_key_pem.as_mut() {
key.zeroize();
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4SendOutput {
pub message_id: String,
pub action: String,
pub traceparent: Option<String>,
pub http_content_type: String,
pub soap_envelope: SoapEnvelope,
pub ref_to_message_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4ReceivePushRequest {
pub http_content_type: String,
pub payload: Arc<[u8]>,
pub receipt_payload: Option<Vec<u8>>,
pub policy: As4PushPolicy,
pub authenticated_sender_scope: Option<Arc<str>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedWsAddressingHeaders {
pub message_id: Option<String>,
pub action: Option<String>,
pub to: Option<String>,
pub reply_to: Option<String>,
}
impl ParsedWsAddressingHeaders {
#[inline]
pub fn is_cef_conformant(&self) -> bool {
self.message_id.is_some() && self.action.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedAs4UserMessage {
pub message_id: String,
pub action: String,
pub from_party_ids: Vec<String>,
pub to_party_ids: Vec<String>,
pub mpc: Option<String>,
pub conversation_id: Option<String>,
pub has_ws_security_header: bool,
pub service: Option<String>,
pub ref_to_message_id: Option<String>,
pub original_sender: Option<String>,
pub final_recipient: Option<String>,
pub tracking_identifier: Option<String>,
pub timestamp: Option<String>,
pub wsa_headers: Option<ParsedWsAddressingHeaders>,
}
impl ParsedAs4UserMessage {
#[inline]
pub fn from_party_id(&self) -> &str {
&self.from_party_ids[0]
}
#[inline]
pub fn to_party_id(&self) -> &str {
&self.to_party_ids[0]
}
pub fn check_timestamp_freshness(
&self,
window: std::time::Duration,
) -> crate::core::Result<()> {
use crate::core::{AsxError, ErrorCode, ErrorContext};
let ts_str = match &self.timestamp {
Some(s) => s,
None => return Ok(()),
};
let ts_secs = crate::time_utils::parse_rfc3339_to_unix_secs(ts_str).ok_or_else(|| {
AsxError::new(
ErrorCode::ParseFailed,
format!("eb:Timestamp value '{ts_str}' is not a valid RFC 3339 timestamp"),
ErrorContext::new("as4_timestamp_freshness"),
)
})?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(std::time::Duration::ZERO)
.as_secs() as i64;
let delta_secs = (now_secs - ts_secs).unsigned_abs();
let window_secs = window.as_secs();
if delta_secs > window_secs {
return Err(AsxError::new(
ErrorCode::SecurityVerificationFailed,
format!(
"eb:Timestamp is outside the freshness window (delta={}s, allowed={}s); \
message rejected to prevent replay",
delta_secs, window_secs,
),
ErrorContext::new("as4_timestamp_freshness")
.with_message_id(self.message_id.clone()),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedAs4Receipt {
pub ref_to_message_id: String,
pub is_signed: bool,
pub has_non_repudiation_info: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4ReceivePushOutput {
pub payload: DomainReady<Arc<[u8]>>,
pub payload_content_id: String,
pub additional_payloads: Vec<As4ReceivedPayload>,
pub sbdh_header: Option<SbdhHeader>,
pub user_message: ParsedAs4UserMessage,
pub receipt: Option<ParsedAs4Receipt>,
}
impl As4ReceivePushOutput {
#[must_use]
pub fn is_test_service_ping(&self) -> bool {
crate::as4::test_service::is_test_service_message(&self.user_message)
}
pub fn payloads(&self) -> impl Iterator<Item = (&str, &DomainReady<Arc<[u8]>>)> {
std::iter::once((self.payload_content_id.as_str(), &self.payload)).chain(
self.additional_payloads
.iter()
.map(|p| (p.content_id.as_str(), &p.payload)),
)
}
pub fn payload_count(&self) -> usize {
1 + self.additional_payloads.len()
}
pub fn payload_by_content_id(&self, content_id: &str) -> Option<&DomainReady<Arc<[u8]>>> {
let wanted = content_id
.strip_prefix("cid:")
.or_else(|| content_id.strip_prefix("CID:"))
.unwrap_or(content_id);
self.payloads()
.find(|(cid, _)| *cid == wanted)
.map(|(_, payload)| payload)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4ReceivedPayload {
pub content_id: String,
pub payload: DomainReady<Arc<[u8]>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum As4ReceivePushProgress {
PendingFragment {
group_id: String,
received_fragments: usize,
expected_fragments: Option<usize>,
},
Complete(Box<As4ReceivePushOutput>),
Duplicate {
message_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum As4ReceiveOutcome {
FirstSeen(Box<As4ReceivePushOutput>),
Duplicate { message_id: String },
}
impl As4ReceiveOutcome {
#[inline]
pub fn is_first_seen(&self) -> bool {
matches!(self, Self::FirstSeen(_))
}
#[inline]
pub fn is_duplicate(&self) -> bool {
matches!(self, Self::Duplicate { .. })
}
#[inline]
pub fn into_output(self) -> Option<As4ReceivePushOutput> {
match self {
Self::FirstSeen(output) => Some(*output),
Self::Duplicate { .. } => None,
}
}
#[inline]
pub fn unwrap_output(self) -> As4ReceivePushOutput {
match self {
Self::FirstSeen(output) => *output,
Self::Duplicate { ref message_id } => {
panic!("called unwrap_output on a Duplicate (message_id={message_id})")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4QueuedPullMessage {
pub message_id: Arc<str>,
pub http_content_type: Arc<str>,
pub payload: Arc<[u8]>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4NriReference {
pub uri: String,
pub digest_method_uri: String,
pub digest_value_b64: String,
}
impl From<&WsSecSignatureReference> for As4NriReference {
fn from(r: &WsSecSignatureReference) -> Self {
Self {
uri: r.uri.clone(),
digest_method_uri: r.digest_method.algorithm_uri().to_string(),
digest_value_b64: r.digest_value_base64.clone(),
}
}
}
impl From<WsSecSignatureReference> for As4NriReference {
fn from(r: WsSecSignatureReference) -> Self {
Self {
uri: r.uri,
digest_method_uri: r.digest_method.algorithm_uri().to_string(),
digest_value_b64: r.digest_value_base64,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct As4ReceivedError {
pub error_code: String,
pub severity: Option<String>,
pub category: Option<String>,
pub origin: Option<String>,
pub ref_to_message_id: Option<String>,
pub short_description: Option<String>,
pub description: Option<String>,
pub error_detail: Option<String>,
}
impl As4ReceivedError {
pub fn code(&self) -> Option<As4ErrorCode> {
As4ErrorCode::from_ebms_code(&self.error_code)
}
pub fn parsed_severity(&self) -> Option<As4ErrorSeverity> {
self.severity
.as_deref()
.and_then(As4ErrorSeverity::from_ebms_severity)
}
pub fn is_failure(&self) -> bool {
!matches!(self.parsed_severity(), Some(As4ErrorSeverity::Warning))
}
pub fn summary(&self) -> String {
let mut summary = self.error_code.clone();
if let Some(short) = &self.short_description {
summary.push_str(" (");
summary.push_str(short);
summary.push(')');
}
if let Some(description) = self.description.as_ref().or(self.error_detail.as_ref()) {
summary.push_str(": ");
summary.push_str(description);
}
summary
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct As4ErrorSignal {
pub message_id: Option<String>,
pub ref_to_message_id: Option<String>,
pub timestamp: Option<String>,
pub errors: Vec<As4ReceivedError>,
}
impl As4ErrorSignal {
pub fn is_failure(&self) -> bool {
self.errors.is_empty() || self.errors.iter().any(As4ReceivedError::is_failure)
}
pub fn summary(&self) -> String {
if self.errors.is_empty() {
return "eb:Error signal with no eb:Error entries".to_string();
}
self.errors
.iter()
.map(As4ReceivedError::summary)
.collect::<Vec<_>>()
.join("; ")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct As4VerifiedReceipt {
pub message_id: Option<String>,
pub ref_to_message_id: String,
pub timestamp: Option<String>,
pub signed: bool,
pub signer_fingerprint_sha256: Option<String>,
pub non_repudiation: crate::as4::As4NonRepudiation,
}
impl As4VerifiedReceipt {
pub fn is_non_repudiation_evidence(&self) -> bool {
self.signed && self.non_repudiation.is_verified()
}
}
#[derive(Clone)]
pub struct As4PullRequestCredentials {
pub signing_key_pem: Vec<u8>,
pub signing_cert_pem: Vec<u8>,
pub key_info_profile: WsSecOutboundKeyInfoProfile,
}
impl std::fmt::Debug for As4PullRequestCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("As4PullRequestCredentials")
.field(
"signing_key_pem",
&crate::core::redact_present(!self.signing_key_pem.is_empty()),
)
.field("signing_cert_pem", &self.signing_cert_pem)
.field("key_info_profile", &self.key_info_profile)
.finish()
}
}
impl Drop for As4PullRequestCredentials {
fn drop(&mut self) {
self.signing_key_pem.zeroize();
}
}
#[derive(Clone)]
pub struct As4ReceiptCredentials {
pub signing_key_pem: Vec<u8>,
pub signing_cert_pem: Vec<u8>,
pub key_info_profile: WsSecOutboundKeyInfoProfile,
}
impl std::fmt::Debug for As4ReceiptCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("As4ReceiptCredentials")
.field(
"signing_key_pem",
&crate::core::redact_present(!self.signing_key_pem.is_empty()),
)
.field("signing_cert_pem", &self.signing_cert_pem)
.field("key_info_profile", &self.key_info_profile)
.finish()
}
}
impl Drop for As4ReceiptCredentials {
fn drop(&mut self) {
self.signing_key_pem.zeroize();
}
}
#[derive(Debug, Clone)]
pub struct As4GeneratePullRequestPolicy {
pub mpc: String,
pub message_id: String,
pub credentials: Option<As4PullRequestCredentials>,
pub authorization_info: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4PullPolicy {
pub interop: InteropMode,
pub mpc: String,
pub interop_exceptions: InteropExceptionPolicy,
pub require_signed_receipt: bool,
#[doc(hidden)]
pub(crate) require_signed_push: bool,
pub fail_closed_audit_events: bool,
pub authorization: As4PullAuthorization,
}
#[derive(Clone, PartialEq, Eq, Default)]
pub enum As4PullAuthorization {
#[default]
Deny,
SharedSecret(String),
EnforcedByTransport,
}
impl std::fmt::Debug for As4PullAuthorization {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Deny => f.write_str("Deny"),
Self::SharedSecret(secret) => f
.debug_tuple("SharedSecret")
.field(&crate::core::redact_present(!secret.is_empty()))
.finish(),
Self::EnforcedByTransport => f.write_str("EnforcedByTransport"),
}
}
}
const DEFAULT_MPC: &str =
"http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/defaultMPC";
impl Default for As4PullPolicy {
fn default() -> Self {
Self {
interop: InteropMode::Strict,
mpc: String::from(DEFAULT_MPC),
interop_exceptions: InteropExceptionPolicy::default(),
require_signed_receipt: true,
require_signed_push: true,
fail_closed_audit_events: true,
authorization: As4PullAuthorization::Deny,
}
}
}
impl As4PullPolicy {
pub fn strict() -> Self {
Self::default()
}
pub fn regulated() -> Self {
Self {
interop: InteropMode::Strict,
mpc: String::from(DEFAULT_MPC),
interop_exceptions: InteropExceptionPolicy::default(),
require_signed_receipt: true,
require_signed_push: true,
fail_closed_audit_events: true,
authorization: As4PullAuthorization::Deny,
}
}
pub fn require_signed_push(&self) -> bool {
self.require_signed_push
}
}
#[derive(Debug, Default)]
pub struct As4PullPolicyBuilder(As4PullPolicy);
impl As4PullPolicyBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn interop(mut self, mode: InteropMode) -> Self {
self.0.interop = mode;
self
}
pub fn mpc(mut self, mpc: impl Into<String>) -> Self {
self.0.mpc = mpc.into();
self
}
pub fn interop_exceptions(mut self, exc: InteropExceptionPolicy) -> Self {
self.0.interop_exceptions = exc;
self
}
pub fn require_signed_receipt(mut self, v: bool) -> Self {
self.0.require_signed_receipt = v;
self
}
#[cfg(feature = "testing")]
pub fn allow_unsigned_push(mut self, allow: bool) -> Self {
self.0.require_signed_push = !allow;
self
}
pub fn fail_closed_audit_events(mut self, v: bool) -> Self {
self.0.fail_closed_audit_events = v;
self
}
pub fn authorization(mut self, authorization: As4PullAuthorization) -> Self {
self.0.authorization = authorization;
self
}
pub fn build(self) -> Result<As4PullPolicy> {
let stage = "as4_pull_policy_build";
validate_strict_as4_policy_consistency(stage, self.0.interop, &self.0.interop_exceptions)?;
validate_strict_as4_receive_policy_consistency(
stage,
self.0.interop,
self.0.require_signed_receipt,
self.0.fail_closed_audit_events,
)?;
if self.0.mpc.trim().is_empty() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"As4PullPolicy.mpc must not be empty",
ErrorContext::new(stage),
));
}
if let As4PullAuthorization::SharedSecret(ref secret) = self.0.authorization
&& secret.trim().is_empty()
{
return Err(AsxError::new(
ErrorCode::InvalidInput,
"As4PullAuthorization::SharedSecret must not be empty — an empty secret \
matches an absent eb:AuthorizationInfo and would authorize every caller",
ErrorContext::new(stage),
));
}
Ok(self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4ReceivePullRequest {
pub pull_message_id: String,
pub policy: As4PullPolicy,
pub receipt_payload: Option<Vec<u8>>,
pub authorization_info: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct As4ReceivePullOutput {
pub pull_message_id: Arc<str>,
pub correlation_message_id: Option<Arc<str>>,
pub mpc: Arc<str>,
pub duplicate_retrieval: bool,
pub pulled: Option<Arc<As4ReceivePushOutput>>,
pub outcome: DeliveryOutcome,
pub retry: RetryDecision,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum As4ErrorCode {
ValueNotRecognized,
FeatureNotSupported,
ValueInconsistent,
Other,
MissingReceipt,
InvalidReceipt,
DecompressionFailure,
}
impl As4ErrorCode {
pub fn ebms_code(self) -> &'static str {
match self {
As4ErrorCode::ValueNotRecognized => "EBMS:0001",
As4ErrorCode::FeatureNotSupported => "EBMS:0002",
As4ErrorCode::ValueInconsistent => "EBMS:0003",
As4ErrorCode::Other => "EBMS:0004",
As4ErrorCode::MissingReceipt => "EBMS:0301",
As4ErrorCode::InvalidReceipt => "EBMS:0302",
As4ErrorCode::DecompressionFailure => "EBMS:0303",
}
}
pub fn from_ebms_code(code: &str) -> Option<Self> {
let code = code.trim();
let (prefix, digits) = code.split_at_checked(5)?;
if !prefix.eq_ignore_ascii_case("EBMS:") {
return None;
}
match digits {
"0001" => Some(As4ErrorCode::ValueNotRecognized),
"0002" => Some(As4ErrorCode::FeatureNotSupported),
"0003" => Some(As4ErrorCode::ValueInconsistent),
"0004" => Some(As4ErrorCode::Other),
"0301" => Some(As4ErrorCode::MissingReceipt),
"0302" => Some(As4ErrorCode::InvalidReceipt),
"0303" => Some(As4ErrorCode::DecompressionFailure),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum As4ErrorSeverity {
Failure,
Warning,
}
impl As4ErrorSeverity {
pub fn as_str(self) -> &'static str {
match self {
As4ErrorSeverity::Failure => "Failure",
As4ErrorSeverity::Warning => "Warning",
}
}
pub fn from_ebms_severity(severity: &str) -> Option<Self> {
let severity = severity.trim();
if severity.eq_ignore_ascii_case("failure") {
Some(As4ErrorSeverity::Failure)
} else if severity.eq_ignore_ascii_case("warning") {
Some(As4ErrorSeverity::Warning)
} else {
None
}
}
}