Security model
Trust boundaries, fail-closed defaults, certificate validation and revocation, XML Signature Wrapping defences, and what asx-rs does not protect against.On this page
Design Principles
- Fail-closed by default — absent configuration causes rejection, not acceptance.
- Explicit trust transitions — trust is not implicit; it must be established at each cryptographic stage.
- No silent bypasses — signature verification results are always propagated;
let _ = verify(...)is forbidden. - Algorithm agility with compliance first — AS4 WS-Security runtime verification is strict-only (no legacy inbound fallback paths); any non-default compatibility behavior must be explicit and scoped through interop policy outside WS-Security cryptographic verification.
- Security invariants are floors, not defaults — a safe default that any configuration layer can silently overwrite is not an invariant. Where a policy layer is publicly mutable, the safe value is additionally pinned by a floor that validation enforces; see Profile security floor.
Profile Security Floor
ProfileStack.overrides and ProfileStack.partner_overrides are public Vec fields, so a single overlay can rewrite SecurityPolicy for one partner. BaseProfile::security_floor is the invariant that survives that.
| Property | Behaviour |
|---|---|
| Default | SecurityPolicy::SIGN_AND_ENCRYPT — matches PEPPOL, CEF eDelivery and BDEW AS4-Profil v1.2 §2.2.6.2.2 |
| Overridable by a layer | No. The floor lives on BaseProfile only, so no extension, global override, partner overlay, or regional pack can lower it |
| Enforced by | ProfileStack::validate(), against the resolved policy of the deployment baseline and of every declared partner |
| Enforced by a host mandate | validate_with_floor(floor) — takes the stronger of the host floor and the profile's, so it can only tighten |
| Visible in audit | Recorded in EffectivePolicySnapshot::security_floor; diff_effective_policy_snapshots grades a lowered floor as High risk and blocks release even when the resolved policy is unchanged |
A layer that weakens security while still clearing the floor is reported as a ProfileLintCode::SecurityRelaxation lint at ProfileLintSeverity::Critical; ProfileValidationOptions::forbid_security_relaxation escalates it to an error.
Interop mode is a separate axis. Neither InteropMode::Strict nor the interop-strict feature implies a strict security policy — they govern header and ambiguity handling. Enforce the security axis with a floor.
Certificate and Trust Configuration
All certificate and trust material is carried in CertHandle, which is set on SessionContext via .with_cert_handle(handle).
pub struct CertHandle {
pub signing_key_pem: String, // PEM private key for signing (AS2 S/MIME or AS4 WS-Security)
pub signing_cert_pem: String, // PEM certificate corresponding to signing_key_pem
pub encryption_cert_pem: String, // PEM certificate for encrypting to this party
pub trust_anchor_pems: Vec<String>, // PEM CA certificates for PKIX chain validation
pub fingerprint_sha256: String, // Expected SHA-256 fingerprint of partner's signing cert (empty = pinning disabled)
pub ocsp_config: OcspConfig, // OCSP mode and responder override
}
PKIX chain validation
When RevocationPolicy::require_chain_validation = true (the default when at least one trust anchor is provided), all signer certificates are validated against trust_anchor_pems using PKIX chain building. An empty trust_anchor_pems with require_chain_validation = true fails closed — no certificate will pass validation.
To explicitly allow any certificate (testing only):
RevocationPolicy {
require_chain_validation: false,
trust_anchor_pems: vec![],
..Default::default()
}
Certificate fingerprint pinning
When fingerprint_sha256 is non-empty, the signer certificate's SHA-256 fingerprint is compared against this value after PKIX validation. A mismatch fails the receive.
AS4 pull authorization
A PullRequest asks the receiver to hand over queued messages, is not signed, and names an MPC that is not secret. As4PullPolicy::authorization therefore defaults to As4PullAuthorization::Deny: an MPC that was never explicitly opened serves nothing. Choose SharedSecret (constant-time comparison against eb:AuthorizationInfo) or EnforcedByTransport (mTLS or an API gateway authorizes the caller upstream). There is no "unauthenticated" setting reachable by leaving a field unset.
WS-Security verification always requires an X.509 signer token, and the verifying key is taken from that certificate. A signature that verifies only against an inline ds:KeyValue/ds:RSAKeyValue is rejected: anyone can generate a key pair and inline its public half, so such a signature is cryptographically valid while authenticating nobody. An inline RSAKeyValue sent alongside a certificate is still checked for consistency with it.
Three KeyInfo shapes resolve to the signer certificate:
| KeyInfo shape | Notes |
|---|---|
ds:X509Data/ds:X509Certificate inline | What ASX emits by default |
wsse:SecurityTokenReference → wsse:BinarySecurityToken ValueType="...#X509v3" | Single DER certificate — the default shape of WSS4J-based stacks (phase4, Domibus, Oxalis) |
wsse:SecurityTokenReference → wsse:BinarySecurityToken ValueType="...#X509PKIPathv1" | DER certificate path, leaf first (BDEW) |
Key-identifier styles that need out-of-document lookup (wsse:KeyIdentifier with SKI or thumbprint, ds:X509IssuerSerial) are not resolvable from the message alone and fail with a named error.
A document carrying multiple ds:Signature elements is resolved fail-closed: exactly one signature directly inside wsse:Security is used (a counter-signature elsewhere in the document is tolerated but not verified); two or more inside wsse:Security, or several with none inside wsse:Security, are rejected as ambiguous rather than resolved by document order — which would let an attacker prepend a signature-shaped element to steer which signature gets verified.
For AS4 push receive, As4PushPolicy now controls trust mode explicitly:
- Pinned sender mode: requires
fingerprint_sha256to be configured for signed inbound message verification.
CertHandle {
fingerprint_sha256: "AA:BB:CC:...".to_string(), // enforce pinning
..
}
OCSP (Online Certificate Status Protocol)
OCSP is configured per session via OcspConfig in CertHandle:
pub struct OcspConfig {
pub mode: OcspMode,
pub responder_override: Option<String>, // Optional URL override
}
pub enum OcspMode {
Disabled,
Required,
BestEffort, // OCSP failure does not fail the receive
}
OCSP responses are fetched via reqwest (async-ocsp feature, enabled by default). The async path (fetch_ocsp_responses_with_cache_async*) is the preferred entry point and runs entirely on the caller's Tokio runtime with no additional thread overhead. The sync compatibility wrapper (fetch_ocsp_responses_with_cache_provider_scoped) bridges into async by using tokio::task::block_in_place + block_on on the current runtime handle — no dedicated OS thread is spawned. Responses are cached (ProcessLocalOcspResponseCache, 5-minute TTL, max 512 entries with lazy eviction) to minimise live round-trips — in steady state, most certificate checks are served from cache.
Response freshness
OCSP responses are validated for freshness:
thisUpdatemust be within 300 seconds of the current time (clock skew tolerance).nextUpdatemust not be more than 86400 seconds (24 hours) in the past.
Stale or expired OCSP responses are rejected.
Outbound requests are validated and pinned
Three paths make an outbound HTTP request, and two take their target from something the counterparty influences:
| Path | Target chosen by |
|---|---|
| AS2/AS4 egress | The deployment's own P-Mode or partner configuration |
| SMP lookup | The SML zone (operator) plus a participant identifier (not) |
| OCSP | The certificate's AIA extension — by whoever presented the certificate, before it is trusted |
All three refuse private, loopback and link-local targets, refuse non-HTTP schemes, and never follow redirects: a 3xx would reach a host nothing checked.
Validating is not enough on its own. Resolving DNS to check the address and then letting the HTTP client resolve again is a time-of-check/time-of-use hole — the attacker answers the check with a public address and the connection with a private one. Every path therefore pins the connection to the addresses that were validated.
Plain HTTP is refused everywhere except OCSP: RFC 6960 responses are signed by the responder, and public PKIs publish http:// responder URLs, so requiring TLS there would break revocation checking rather than improve it.
Cryptographic Algorithms
AS2 (S/MIME)
| Operation | Algorithm |
|---|---|
| Signing | S/MIME CMS — RSA + SHA-256 |
| Encryption | S/MIME CMS — AES-256 CBC (or AES-128-GCM for newer partners) |
| MIC computation | SHA-256 over the signed/decrypted MIME entity, or over content alone for unprotected messages (RFC 4130 §7.3.1 — see as2.md) |
AS4 (WS-Security / XML Encryption)
| Operation | Algorithm | URI |
|---|---|---|
| Payload encryption (outbound) | AES-256-GCM (XMLenc11) | http://www.w3.org/2009/xmlenc11#aes256-gcm |
| Payload encryption (inbound) | AES-128-GCM or AES-256-GCM (XMLenc11) | http://www.w3.org/2009/xmlenc11#aes128-gcm, http://www.w3.org/2009/xmlenc11#aes256-gcm |
| Key transport | RSA-OAEP (XMLenc11) | http://www.w3.org/2009/xmlenc11#rsa-oaep |
| Key transport MGF | MGF1-SHA256 | http://www.w3.org/2009/xmlenc11#mgf1sha256 |
| Key transport digest | SHA-256 | http://www.w3.org/2001/04/xmlenc#sha256 |
| XML Signature | RSA-SHA256 | http://www.w3.org/2001/04/xmldsig-more#rsa-sha256 |
| Canonicalization | Exclusive C14N | http://www.w3.org/2001/10/xml-exc-c14n# |
ASX uses AES-256-GCM (authenticated encryption) for outbound AS4 encryption and accepts only XMLenc11 AES-GCM inbound. Legacy AES-CBC and XMLenc 1.0 OAEP variants are rejected fail-closed to reduce downgrade and padding-oracle risk.
WS-Security XML Signatures
Canonicalization (C14N)
WS-Security signature computation uses XML Exclusive C14N (W3C exc-c14n).
Whitespace is signed. Exclusive and Inclusive C14N differ only in namespace rendering; neither removes whitespace-only text nodes. ASX has no option to strip them — a
strip_blank_textknob existed through 0.12.0 and made every signature non-interoperable. Conformance is now checked against xmlsec1, the XMLDSig reference implementation, bytests/interop_xmlsec_oracle.rs.
The implementation:
- Correctly handles namespace propagation for visibly-utilized namespaces, including the
xmlns=""undeclaration for a no-namespace element under an inherited non-empty default namespace (Exc-C14N §2.3). - Forwards processing instruction nodes (
<?target data?>). - Implements
InclusiveNamespaces PrefixListper W3C Exc-C14N §2.1 — ancestor namespace bindings for listed prefixes are rendered even when not directly utilized at the element. The list is honored both on per-referenceds:Transforms and on theds:SignedInfoCanonicalizationMethoditself, where WSS4J-based signers declare their SOAP envelope prefix by default. - Strips comments in default mode; preserves them when
include_comments = true. - Sorts attributes in lexicographic order (namespace URI, local name) as required by C14N.
For cid: attachment references, the WSS SwA profile Attachment-Content-Signature-Transform is declared on send and accepted on verify — the AS4 profile mandates it, and its digest input for binary content is the raw attachment octets. The Attachment-Complete-Signature-Transform (MIME part headers folded into the digest) is not implemented and is rejected by name.
Validated against W3C C14N test vectors (namespace propagation, attribute ordering, text/attribute escaping, PI forwarding, comment stripping, comment preservation).
Payload attachment coverage
The AS4 payload — the actual business document — travels as a detached MIME part, not inside the SOAP envelope. Signing eb:Messaging and the SOAP Body therefore proves nothing about it.
On receive, when a signature is present, ASX requires the payload attachment to be covered by a verified ds:Reference URI="cid:…" matching the attachment's Content-ID. A message whose signature omits the attachment reference is rejected with ErrorCode::SecurityVerificationFailed — otherwise an intermediary could swap the payload without invalidating the signature.
Two supporting rules make this enforceable:
| Rule | Why |
|---|---|
The envelope must reference the attachment by href="cid:…" — an eb:PartInfo in the signed eb:Messaging header (SwA, the AS4 packaging), or a legacy MTOM xop:Include in the Body | Without a Content-ID there is nothing to match a cid: reference against, so coverage could never be proven. A multipart message without one is rejected as having no payload attachment. |
href is matched in both XML quoting styles | href='cid:…' is legal XML; matching only the double-quoted form left the attachment unidentified. |
Signed scope and XML Signature Wrapping defence
The AS4 push signature covers three references: the entire eb:Messaging header block (wsu:Id="as4-messaging" — all UserMessage routing/authorization metadata: From, To, Service, Action, MPC, MessageProperties, PartInfo), the SOAP Body, and a detached cid: reference for the MIME payload attachment. (Earlier revisions signed only ebms:MessageId, leaving the routing metadata tamperable.)
On receive, verification returns the set of verified same-document wsu:Ids and the AS4 layer requires that the document contains exactly one eb:Messaging block whose wsu:Id is in that set. This binds the block the pipeline routes on to the block the signature actually covered, defeating XML Signature Wrapping (relocating the signed element and injecting an unsigned replacement).
Signature verification
Signature verification uses:
- Digest verification over C14N-serialized referenced elements.
- RSA/ECDSA signature verification using
secure_eq(constant-time comparison) for digest values. - Minimum signing-key strength enforcement (RSA
< 2048bits is rejected). - PKIX chain validation of the signing certificate.
- OCSP status check (if configured).
- Binding of the consumed
eb:Messagingblock to the verified signature (above).
Verification is fail-closed: any error at any step propagates immediately via ?. The caller cannot ignore a failed verification.
wsu:Timestamp validation
Inbound WS-Security timestamps are validated:
wsu:Createdmust be within 5 minutes of the current time.wsu:Expires(if present) must not be in the past.
Outbound timestamps include wsu:Created (now) and wsu:Expires (now + 5 minutes).
Non-Repudiation of Receipt (AS4 send path)
A counterparty's eb:Receipt is only delivery evidence once it has been checked against the message that was actually sent. asx_rs::as4::verify_sync_response (and As4HttpTransport::send_and_verify) performs that check; nothing else in the send path does.
Threat model for the receipt, and what defends against each:
| Threat | Defence |
|---|---|
| Attacker or misconfigured MSH returns a receipt it did not sign | WS-Security signature verified against the pinned partner certificate (cert_handle.fingerprint_sha256), trust anchors and revocation policy |
| Signature wrapping — leave a genuinely signed element in place so the signature verifies, and append an unsigned acknowledgement for another message | the eb:SignalMessage acted on must itself be covered by the verified signature, directly or via a signed ancestor; at most one eb:Messaging and one eb:SignalMessage are accepted |
Element injection — a duplicate eb:RefToMessageId / eb:MessageId / eb:Timestamp shadowing the real one | a repeated eb:MessageInfo child is rejected outright rather than resolved first-wins |
Digests parked outside the eb:Receipt to fake non-repudiation | NonRepudiationInformation is read only from inside the eb:Receipt of that SignalMessage |
| Counterparty acknowledges a different message | eb:RefToMessageId compared to the sent message_id |
| Counterparty acknowledges different bytes — the core NRR guarantee | every ds:Reference of the sent message's own signature must be echoed by a MessagePartNRInformation entry with a matching digest algorithm and digest value |
| Receipt acknowledges only part of a multi-part message | a sent reference with no echoed entry is rejected; the MIME package is unwrapped so cid: attachment references are covered |
| Padding a valid entry with a second, conflicting one for the same URI | duplicate MessagePartNRInformation URIs are rejected outright |
| Entries for URIs the sender never signed | rejected under reject_unexpected_references (on in regulated()) |
| Replay of an old receipt | eb:Timestamp freshness window, 5 minutes by default; a receipt with no timestamp is rejected rather than skipping the check |
A ds:Signature element that is not a verifiable enveloped signature | treated as a verification failure, never downgraded to "unsigned" |
An eb:Error for someone else's message causing a wrongful dead-letter | the error signal's correlation (eb:MessageInfo/eb:RefToMessageId or the eb:Error/@refToMessageId attribute) must match the sent message when present |
| A signal that both acknowledges and rejects | rejected as ambiguous — neither confirmed delivered nor confirmed rejected |
| Resource exhaustion from a hostile response | 256 KiB body cap, XML element-count cap, bounded NRI and error-entry counts |
The same wrapping and ambiguity defences apply to the inbound receipt path (receipt_payload on As4ReceivePushRequest), which shares the parser and the signature-coverage binding.
Error signals are not authenticated
As4SyncSignal::Error reports what the connection returned; it does not prove it. Error signals are typically unsigned, so treat one as a routing hint (retry vs dead-letter), never as evidence about the message's fate. Only As4VerifiedReceipt::is_non_repudiation_evidence() asserts a cryptographically proven outcome.
A digest that is present and wrong is always an error (SecurityVerificationFailed) regardless of policy — As4NonRepudiation has no "mismatch" variant, so a mismatch cannot be returned as a value the caller might ignore. As4ReceiptPolicy::require_non_repudiation = false only tolerates the absence of digests, and the resulting As4NonRepudiation::NotProvided makes As4VerifiedReceipt::is_non_repudiation_evidence() return false.
Do not detect receipts by scanning the response body for <eb:Receipt. Namespace prefixes are arbitrary and element text may be CDATA-wrapped, so a substring match yields false delivery failures against conformant partners — and it cannot verify non-repudiation at all.
InsecureBypassTrustVerifier
use asx_rs::lifecycle::InsecureBypassTrustVerifier;
For testing only. This verifier passes any payload as fully trusted and decryptable without performing any cryptographic checks. Its name is intentionally explicit.
Never use InsecureBypassTrustVerifier in production. It bypasses:
- Signature verification
- PKIX chain validation
- OCSP status checking
- Fingerprint pinning
Payload Size Limits
All inbound reads are bounded. The default limit is 256 MiB (DEFAULT_MAX_BODY_BYTES). This applies to:
asx_rs::as2::receive_with_mdn_with_reliabilityasx_rs::as4::receive_push_with_dedup_synctransport::serverlayer (axum handlers)
Override per-session:
As2PushPolicy::builder().max_body_bytes(64 * 1024 * 1024) // 64 MiB
An over-limit body fails with ErrorCode::PayloadTooLarge, which maps to HTTP 413. (Before v0.11.0 wire::enforce_payload_limit returned PolicyViolation → HTTP 403, misreporting an oversize body as an authorization failure; ingress handlers matching on PolicyViolation for size must be updated.)
AS4 synchronous receipts are bounded separately and much more tightly at 256 KiB (as4::DEFAULT_MAX_RECEIPT_BYTES, tunable via As4ReceiptPolicy::max_receipt_bytes) — a SignalMessage carries no business payload, so anything larger is malformed or hostile.
Temp File Security
Streaming receive operations that require on-disk spooling (e.g., for signature verification rewinding) use tempfile::NamedTempFile for atomic, exclusive temp file creation. This prevents symlink attacks on world-writable /tmp.
Operator Hardening Expectations (Core Dumps and Host Memory)
ASX zeroizes owned private-key PEM buffers on drop where possible, and recent send-path refactors minimize transient key-buffer duplication. However, ASX is only a library and cannot enforce host OS process-dump policy, swap policy, or debugger attach policy.
Production operators are expected to harden runtime environments accordingly:
- Disable process core dumps for ASX-hosting services (for example
ulimit -c 0, systemdLimitCORE=0, container runtime equivalents). - Restrict dumpability and ptrace/debug attachment to trusted operators only.
- Ensure swap/pagefile policy is encrypted or disabled for regulated deployments handling private keys.
- Keep crash-reporting pipelines from uploading raw process memory unless a formally approved secret-scrubbing policy is in place.
These controls are mandatory complements to in-process zeroization when handling cryptographic private key material in production.
SMP Discovery Verification
smp::SmpClient resolves the AS4 endpoint URL and the recipient's certificate. TLS authenticates the SMP host, not the metadata it serves, so a rogue or compromised SMP could otherwise redirect traffic and substitute its own recipient certificate. Verify the response signature with SmpSignaturePolicy::Verify.
| Control | Status |
|---|---|
| SMP URL SSRF validation + DNS pinning + no redirects | ✅ enforced — the lookup validates and pins in one step, so the addresses that were checked are the ones connected to |
The NAPTR record naming the SMP may only yield an https URI, and may not use a backreference | ✅ enforced — the SMP URL is read out of DNS, so the record's contents are an input to where a message goes |
| The result is usable at all | ⛔ refused by default (SmpSignaturePolicy::Deny) — a lookup whose authenticity was never established does not silently become a routing decision |
ds:Signature present on the response | ✅ with SmpSignaturePolicy::RequireSignaturePresent — catches an unsigned SMP and nothing else |
ds:Signature verifies | ✅ with SmpSignaturePolicy::Verify |
| SMP certificate chains to the network SMP CA | ✅ with SmpSignaturePolicy::Verify |
let config = SmpConfig {
signature_policy: SmpSignaturePolicy::verify_with_trust_anchors(vec![smp_ca_pem]),
..SmpConfig::peppol_production()
};
SmpEndpoint::verified_signer_fingerprint_sha256 is Some only when the signature was actually verified — check it rather than assuming, since the other two policies leave it None. SmpEndpoint::signed_document still returns the exact response bytes for callers that want to verify independently.
Assurance note. The enveloped whole-document path is newer than the AS4 signing path and is not yet cross-validated against an independent implementation, so treat it accordingly. DNSSEC validation of the SML zone is the resolver's job — the Peppol zones are signed, and BdxlResolver is where a deployment supplies a validating client.
Known Limitations
| Limitation | Mitigation |
|---|---|
| SMP enveloped-signature path not yet cross-validated against another stack | Pin partner certificates out of band as defence in depth (see above) |
| Custom XML Exclusive C14N implementation | Validated against W3C test vectors and interop compatibility matrix; not yet replaced by a vetted library |
| In-memory dedup provides no replay protection across restarts | Use TtlDedupStorage with a distributed backend for production; document required 48h window per RFC 4130 §5.2.1 |
| No TLS mutual authentication (mTLS) at the library level | Configure mTLS at the TLS terminator / reverse proxy layer |
OCSP thisUpdate/nextUpdate clock skew tolerance is fixed at ±300s / 86400s | Adjust via OcspConfig if partner OCSP responders have larger clock drift |
OCSP sync wrapper uses block_in_place/block_on (not a new OS thread) — must be called from a multi-thread Tokio runtime | Use the async fetch_ocsp_responses_with_cache_async entry point directly from async call sites; at very high message rates with many distinct certificates, consider a shared persistent OCSP cache backend |
Crypto Backend Roadmap
Current State: Mixed OpenSSL + Pure-Rust
asx-rs currently uses two separate crypto ecosystems:
| Subsystem | Current backend | Role |
|---|---|---|
| AS2 S/MIME signing / encryption | openssl (C FFI) | CMS SignedData / EnvelopedData |
| AS4 WS-Security XML signing | openssl (C FFI) | RSA-SHA256 ds:Signature, RSA-OAEP key wrap |
| AS4 payload symmetric encryption | aes-gcm (pure Rust) | AES-128/256-GCM xenc:EncryptedData |
| X.509 certificate parsing | openssl (C FFI) | Trust-anchor validation, chain building, OCSP |
This mixed model has several implications:
- Build system: consumers must have a working OpenSSL installation (or accept the
openssl-sysvendored build). Cross-compilation (e.g., tox86_64-unknown-linux-muslstatic binaries) requires extra care. - FIPS compliance: OpenSSL can be compiled in FIPS mode; the
aes-gcmcrate is not FIPS 140-2 validated. Regulated deployments (US federal, healthcare) requiring FIPS-validated crypto across all algorithms must either replaceaes-gcmwith the OpenSSL AES-GCM primitives or wait for the pure-Rust migration path below. - Vulnerability management: OpenSSL and
aes-gcmhave separate CVE timelines and patch cadences. Both must be tracked independently.
Removing the OpenSSL dependency
Converging on a single pure-Rust crypto stack is a direction this project is committed to, not a dated plan: the symmetric layer already is pure Rust, and X.509, RSA and CMS are not. It will land as a breaking-dependency change and be announced in CHANGELOG.md.
FIPS Deployment Today
If you need FIPS-validated crypto today, use the following configuration:
- Compile OpenSSL in FIPS mode (OpenSSL 3.x with
OPENSSL_FIPS=1). - Do not enable AS4 XML encryption (set
As4SendPolicy { encrypt: false, .. }for outbound; for inbound,As4PushPolicy::default()already allows unencrypted payloads when none arrive encrypted), since theaes-gcmsymmetric layer is not FIPS 140-2 validated. - Contact your compliance officer before enabling AS4 payload encryption in a regulated deployment: the symmetric layer it uses is not FIPS-validated.