use crate::core::{
AsxError, ErrorCode, ErrorContext, InteropMode, ReceivedBodyHandle, Result, SessionContext,
SpoolEncryption, SpoolLifecyclePolicy,
};
#[cfg(feature = "as2")]
pub use crate::crypto::as2_smime::SmimeCipher;
#[cfg(feature = "as2")]
use crate::crypto::as2_smime::sign_smime_message;
#[cfg(test)]
use crate::interop::InteropExceptionPolicy;
use crate::interop::{
InteropDecision, InteropExceptionCode, enforce_exception, evaluate_exception_guardrail,
};
#[cfg(test)]
use crate::lifecycle::TrustEvidence;
use crate::lifecycle::{DomainReady, UntrustedBytes};
use crate::observability::{
AsxEvent, AsxIngressStage, AsxProtocol, EventBus, emit_audit_event, emit_protocol_event,
};
use crate::reliability::{
DeliveryOutcome, ReconciliationReason, ReconciliationRequest, RetryDecision,
derive_ingress_idempotency_key, derive_reconciliation_idempotency_key,
};
use crate::send_pipeline as pipeline;
use crate::storage::{DedupStorage, ReconciliationStorage, drive_dedup_future};
use crate::wire::{
DEFAULT_MAX_BODY_BYTES, StreamBodyPolicy, StreamLimits, StreamReadMetrics,
enforce_payload_limit,
};
use std::sync::Arc;
mod auth_telemetry;
mod ingress;
mod mdn;
mod mic;
mod send_path;
mod spool_http_helpers;
mod spool_key_provider;
mod spool_policy;
mod spool_provider_backends;
mod spool_runtime_utils;
mod stream_receive;
mod trust;
mod types;
#[cfg(feature = "client")]
pub use auth_telemetry::compute_http_spool_key_auth_telemetry_labels;
pub use ingress::{As2IngressReceiveRequest, receive_from_ingress};
pub use send_path::{
As2SendPreparedRequest, As2SendRequest, send_async, send_async_prepared, send_sync,
send_sync_prepared,
};
pub use spool_key_provider::As2RegulatedSpoolKeyProvider;
pub use stream_receive::{
receive_stream, receive_stream_with_metrics, receive_stream_with_metrics_and_audit,
};
#[cfg(test)]
use spool_policy::regulated_stream_body_policy_build_with_provider;
pub(crate) use spool_policy::{StreamBodyPolicyBuildOutcome, as2_stream_body_policy_build};
#[cfg(test)]
pub use trust::InsecureBypassTrustVerifier;
#[cfg(feature = "testing")]
pub use trust::TrustVerifierSeal;
#[cfg(feature = "testing")]
pub(crate) use trust::private;
pub use trust::{
As2TrustVerifier, AsyncAs2TrustVerifier, CmsSmimeTrustVerifier, SyncToAsyncTrustVerifier,
TrustResult,
};
#[cfg(feature = "as2")]
pub use types::validate_as2_version_header;
pub use types::{
As2GeneratedMdn, As2InboundResult, As2MdnMode, As2MdnSigningCredentials, As2MicAlgorithm,
As2PreparedSendCredentials, As2ReceiveMdnOutput, As2ReceiveMdnRequest, As2ReceivePolicy,
As2ReceivePolicyBuilder, As2SendCredentials, As2SendOutput, As2SendPolicy, MimeEnvelope,
ParsedMdn, payload_content_type,
};
use mdn::{classify_mdn_outcome, extract_content_type_header, parse_mdn};
use spool_http_helpers::fetch_spool_key_hex_over_http;
#[cfg(all(feature = "client", test))]
use spool_http_helpers::{
ensure_http_key_provider_circuit_allows_request, http_key_provider_backoff_for_attempt,
key_response_signing_input, note_http_key_provider_circuit_failure,
note_http_key_provider_circuit_success, resolve_http_key_provider_client_identity_pem,
validate_http_key_provider_mtls_policy,
};
#[cfg(test)]
use spool_provider_backends::HttpJsonSpoolEncryptionKeyProvider;
#[cfg(all(feature = "client", test))]
use spool_provider_backends::HttpKeyProviderResilienceConfig;
#[cfg(all(feature = "client", test))]
use spool_provider_backends::HttpKeyProviderTlsConfig;
use spool_provider_backends::{SpoolEncryptionKeyProvider, regulated_spool_key_provider};
use spool_runtime_utils::{
as2_spool_threshold_for_profile, parse_spool_encryption_key_hex,
profile_requires_encrypted_spool, validate_spool_encryption_key_startup_self_test,
};
#[cfg(test)]
use stream_receive::{
emit_stream_ingest_observations, maybe_emit_provider_health_state_transition,
};
const MAX_AS2_PAYLOAD_BYTES: usize = DEFAULT_MAX_BODY_BYTES;
const MAX_AS2_MDN_BYTES: usize = 256 * 1024;
#[cfg_attr(feature = "trace", tracing::instrument(skip_all, fields(partner_id = %session.partner_id())))]
pub fn receive_sync(
session: &SessionContext,
payload: Vec<u8>,
verifier: &dyn As2TrustVerifier,
) -> Result<DomainReady<Arc<[u8]>>> {
crate::presets::enforce_strict_runtime_bootstrap_for_strict_interop(
"as2_receive_sync",
session,
InteropMode::Strict,
)?;
receive_payload(session, crate::core::PayloadInput::Owned(payload), verifier)
}
pub async fn receive_async(
session: &SessionContext,
payload: Vec<u8>,
verifier: Arc<dyn As2TrustVerifier + Send + Sync>,
) -> Result<DomainReady<Arc<[u8]>>> {
let permit = crate::core::CryptoAdmissionControl::process_global()
.acquire("as2_receive_async_admission", session)
.await?;
let blocking_session = session.clone();
let error_session = session.clone();
tokio::task::spawn_blocking(move || {
let _permit = permit;
receive_sync(&blocking_session, payload, verifier.as_ref())
})
.await
.map_err(|err| {
AsxError::new(
ErrorCode::TransportFailure,
format!("AS2 receive blocking task failed: {err}"),
ErrorContext::for_session("as2_receive_async_join", &error_session),
)
})?
}
fn receive_payload(
session: &SessionContext,
payload_input: crate::core::PayloadInput<'_>,
verifier: &dyn As2TrustVerifier,
) -> Result<DomainReady<Arc<[u8]>>> {
let body = ReceivedBodyHandle::from_payload_input(payload_input);
let payload_len = body.payload_len("as2_receive_parse", session)?;
enforce_payload_limit("as2_receive_parse", payload_len, MAX_AS2_PAYLOAD_BYTES)?;
if payload_len == 0 {
return Err(AsxError::new(
ErrorCode::ParseFailed,
"as2 payload is empty",
ErrorContext::new("as2_receive_parse")
.with_session_and_partner(session.session_id(), session.partner_id()),
));
}
tracing::debug!(
session_id = %session.session_id(),
partner_id = %session.partner_id(),
bytes = payload_len,
"AS2 receive: verifying payload",
);
let trust = verifier.verify_and_decrypt(session, &body)?;
let domain_bytes: Arc<[u8]> = trust
.decrypted_payload
.unwrap_or(body.into_arc("as2_receive_parse", session)?);
let trusted = UntrustedBytes::new(domain_bytes)
.into_parsed_unchecked()
.verify(trust.signature)?
.decrypt(trust.decryption)?
.into_domain_ready();
Ok(trusted)
}
#[cfg_attr(feature = "trace", tracing::instrument(skip_all, fields(partner_id = %session.partner_id(), original_message_id = ?request.original_message_id)))]
pub fn receive_with_mdn_with_reliability(
session: &SessionContext,
event_bus: &EventBus,
request: As2ReceiveMdnRequest,
reconciliation_hook: &dyn ReconciliationStorage,
dedup_backend: &dyn DedupStorage,
verifier: &dyn As2TrustVerifier,
) -> Result<As2ReceiveMdnOutput> {
crate::presets::enforce_strict_runtime_bootstrap_for_strict_interop(
"as2_receive_with_mdn",
session,
request.policy.interop_mode,
)?;
if request.policy.interop_mode == InteropMode::Strict
&& (request
.policy
.interop_exceptions
.scoped_profile_name
.is_some()
|| !request.policy.interop_exceptions.allowed.is_empty())
{
return Err(AsxError::new(
ErrorCode::PolicyViolation,
"strict AS2 runtime policy forbids configured interop exception overrides",
ErrorContext::for_session("as2_receive_with_mdn", session),
));
}
crate::presets::enforce_strict_production_runtime_receive_guards(
"as2_receive_with_mdn",
session,
event_bus,
request.policy.fail_closed_audit_events,
Some(reconciliation_hook),
Some(dedup_backend),
)?;
let fail_closed = request.policy.fail_closed_audit_events;
let payload = receive_payload(
session,
crate::core::PayloadInput::Shared(Arc::clone(&request.payload)),
verifier,
)?;
let (mdn, interop_reason_codes) = match parse_mdn(
request.mdn_payload.as_ref(),
request.policy,
session,
event_bus,
verifier,
) {
Ok(result) => result,
Err(err) => {
if let Some(original_id) = request.original_message_id.as_deref() {
tracing::warn!(
session_id = %session.session_id(),
partner_id = %session.partner_id(),
original_message_id = %original_id,
error = %err,
"AS2 parse_mdn failed; queuing reconciliation for original message",
);
if let Some(req) = ReconciliationRequest::for_outcome(
original_id,
session.partner_id().to_string(),
DeliveryOutcome::Indeterminate,
) {
reconciliation_hook.enqueue(req).map_err(|enqueue_err| {
AsxError::new(
ErrorCode::ReliabilityFailure,
format!(
"AS2 MDN parse failed and reconciliation enqueue failed: parse={err}; enqueue={enqueue_err}"
),
ErrorContext::for_session("as2_receive_with_mdn", session),
)
})?;
}
}
return Err(err);
}
};
let outcome = classify_mdn_outcome(&mdn, request.mdn_mode, request.expected_mic.as_deref());
let retry_decision = RetryDecision::from_outcome(outcome);
let reconciliation_message_id: Arc<str> = Arc::from(
mdn.original_message_id
.as_deref()
.unwrap_or("unknown")
.to_string(),
);
let dedup_key = derive_ingress_idempotency_key(
session.partner_id(),
"as2_mdn_receive",
&reconciliation_message_id,
);
if !drive_dedup_future(dedup_backend.first_seen(&dedup_key))? {
tracing::warn!(
session_id = %session.session_id(),
partner_id = %session.partner_id(),
message_id = %reconciliation_message_id,
"AS2 duplicate MDN detected, ignoring",
);
emit_audit_event(
event_bus,
session,
AsxEvent::DuplicateDetected {
message_id: reconciliation_message_id.clone(),
key: dedup_key,
ingress: AsxIngressStage::As2ReceiveWithMdn,
},
fail_closed,
"as2_mdn_dedup",
)?;
return Ok(As2ReceiveMdnOutput {
payload,
mdn,
outcome,
retry_decision,
interop_reason_codes,
});
}
for reason in &interop_reason_codes {
emit_protocol_event(
event_bus,
session,
AsxEvent::InteropRelaxationApplied {
message_id: reconciliation_message_id.clone(),
rule: "as2_interop_exception",
detail: reason,
},
fail_closed,
"as2_mdn_interop_relaxation",
)?;
}
emit_protocol_event(
event_bus,
session,
AsxEvent::MdnReceived {
message_id: reconciliation_message_id.clone(),
disposition: mdn.disposition.clone(),
},
fail_closed,
"as2_mdn_received",
)?;
if retry_decision.should_retry {
emit_protocol_event(
event_bus,
session,
AsxEvent::RetryScheduled {
message_id: reconciliation_message_id.clone(),
attempt: 1,
reason: "indeterminate_mdn",
},
fail_closed,
"as2_mdn_retry_scheduled",
)?;
}
if let Some(recon_req) = ReconciliationRequest::for_outcome(
reconciliation_message_id.as_ref(),
session.partner_id().to_string(),
outcome,
) {
let reason = match recon_req.reason {
ReconciliationReason::Indeterminate => "indeterminate",
ReconciliationReason::PendingVerification => "pending_verification",
};
if reconciliation_hook.enqueue(recon_req)? {
emit_protocol_event(
event_bus,
session,
AsxEvent::ReconciliationQueued {
message_id: reconciliation_message_id.clone(),
reason,
},
fail_closed,
"as2_mdn_reconciliation_queued",
)?;
}
}
Ok(As2ReceiveMdnOutput {
payload,
mdn,
outcome,
retry_decision,
interop_reason_codes,
})
}
#[cfg(feature = "as2")]
pub fn generate_signed_mdn(
session: &SessionContext,
original_message_id: &str,
disposition: &str,
received_content_mic: Option<&str>,
signing: &As2MdnSigningCredentials,
) -> Result<Vec<u8>> {
let report = generate_mdn(
session,
original_message_id,
disposition,
received_content_mic,
)?;
sign_smime_message(
&report,
signing.signing_key_pem.as_slice(),
signing.signing_cert_pem.as_slice(),
)
}
pub fn generate_mdn(
session: &SessionContext,
original_message_id: &str,
disposition: &str,
received_content_mic: Option<&str>,
) -> Result<Vec<u8>> {
if original_message_id.trim().is_empty() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"original_message_id must not be empty",
ErrorContext::for_session("as2_generate_mdn", session),
));
}
if disposition.trim().is_empty() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"disposition must not be empty",
ErrorContext::for_session("as2_generate_mdn", session),
));
}
let boundary = mdn_boundary_from_message_id(original_message_id);
let mut notification_part = format!(
"Final-Recipient: rfc822; {}\r\nOriginal-Message-ID: {}\r\nDisposition: {}\r\n",
session.partner_id(),
original_message_id,
disposition
);
if let Some(mic) = received_content_mic {
notification_part.push_str(&format!("Received-Content-MIC: {}\r\n", mic.trim()));
}
let mdn = format!(
"Content-Type: multipart/report; report-type=disposition-notification; boundary=\"{boundary}\"\r\n\
MIME-Version: 1.0\r\n\
\r\n\
--{boundary}\r\n\
Content-Type: text/plain; charset=us-ascii\r\n\
\r\n\
The message has been received and processed by ASX.\r\n\
\r\n\
--{boundary}\r\n\
Content-Type: message/disposition-notification\r\n\
\r\n\
{notification_part}\
\r\n\
--{boundary}--\r\n"
);
Ok(mdn.into_bytes())
}
fn mdn_boundary_from_message_id(original_message_id: &str) -> String {
const OFFSET: u64 = 14_695_981_039_346_656_037;
const PRIME: u64 = 1_099_511_628_211;
let mut h: u64 = OFFSET;
for &b in original_message_id.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(PRIME);
}
format!("asx-mdn-{h:016x}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AsyncMdnCorrelationOutcome {
Resolved { original_message_id: String },
NotPending { original_message_id: String },
NoOriginalMessageId,
}
pub fn correlate_async_mdn(
mdn_bytes: &[u8],
partner_id: &str,
reconciliation: &dyn ReconciliationStorage,
) -> Result<AsyncMdnCorrelationOutcome> {
let original_message_id = match mdn::extract_original_message_id(mdn_bytes) {
Some(id) => id,
None => return Ok(AsyncMdnCorrelationOutcome::NoOriginalMessageId),
};
for reason in [
ReconciliationReason::Indeterminate,
ReconciliationReason::PendingVerification,
] {
let key = derive_reconciliation_idempotency_key(partner_id, &original_message_id, reason);
if reconciliation.resolve(&key)? {
return Ok(AsyncMdnCorrelationOutcome::Resolved {
original_message_id,
});
}
}
Ok(AsyncMdnCorrelationOutcome::NotPending {
original_message_id,
})
}
#[cfg(test)]
mod tests;