AS4 in .NET without a Java MSH: ebMS 3.0, WS-Security and non-repudiation receipts as a route step
If you want a homely analogy, AS4 is a registered letter with a delivery receipt, except the document itself travels over the network instead of on paper. You send it to a partner; the partner acknowledges receipt. The difference from post is that a paper receipt proves only that the envelope arrived, while an AS4 receipt is cryptographically bound to the content: it proves that this exact document arrived, byte for byte.
If you exchange documents with European B2B, you almost certainly already run AS4. It is the OASIS ebMS 3.0 profile that stands behind EU eDelivery: legal documents in e-CODEX and e-Justice, electronic invoices on the Peppol network, gas operators' requests in ENTSOG, electronic transport documents in eFTI. AS4 is the direct successor to AS2, except that where AS2 carried an S/MIME envelope, this wire carries SOAP 1.2 with an eb:Messaging header, WS-Security and a signed receipt. Where AS2 returned an MDN with a MIC, AS4 returns an eb:Receipt with non-repudiation: proof that your partner received exactly the document you signed.
In .NET this world has been badly served. The mature AS4 implementations live in Java: Holodeck B2B, Apache Domibus (the reference EU eDelivery access point), phase4. So next to your .NET backend you run either a separate JVM process or a commercial gateway with its own license, its own inbox directory and its own ops team. The base .NET stack does not cover AS4: no SOAP envelope with ebMS headers, no WS-Security over MIME attachments, no receipt you can actually point to.
redb.Route.As4 puts AS4 inside the route. Sending is a To step; receiving is a From step. The document is assembled, compressed, signed and encrypted, sent to the partner, and a verified receipt comes back. Or the other way around: the envelope arrives, is decrypted, the signature verifies, the document lands in the route and a receipt goes back to the partner. One process, one deployment, one trace tree. Below: what it looks like in code, and where the real difficulty actually lies.
Why AS4, and not "just HTTPS"
Fair question: if the channel is already under TLS, why also sign and encrypt the document on top? The answer is the same as in the AS2 world, and it is not paranoia. TLS protects the channel: it lives from your socket to the partner's socket and ends the moment the bytes hit disk. In a load balancer's log, in a proxy's archive, on the recipient's filesystem, the document is already in the clear. AS4 protects the document itself: it stays signed and encrypted the whole way and at rest, and only the holder of the private key can decrypt it.
The bigger point is the receipt. TLS has none. AS4 does: the receiver answers with a signed receipt whose non-repudiation references point at exactly what you signed. That is non-repudiation. When a document carries money, deadlines or a legal obligation, you do not just need to know it was delivered, you need proof that this exact document was delivered. That is why AS4 is mandatory wherever the document is a legal commitment and not a letter.
AS4 in one minute
On the wire it is an HTTP POST with multipart/related and SOAP 1.2. The envelope has two parts.
The header. eb:Messaging carries the business metadata: eb:UserMessage with the message id, the parties (eb:PartyId), the service and action (eb:Service, eb:Action), and the four mandatory four-corner properties (originalSender, finalRecipient). Next to it sits the wsse:Security block: wsu:Timestamp, the sender's certificate (wsse:BinarySecurityToken), the encrypted session key (xenc:EncryptedKey), the signature (ds:Signature) and one xenc:EncryptedData per encrypted attachment.
The attachments. The business document is not in the SOAP body; it travels as separate MIME parts (SOAP with Attachments, SwA). A part is gzipped, then encrypted, then the whole message is signed. The SOAP body is usually empty. This is the key difference from our SOAP connector, which speaks MTOM: in AS4 the payload always rides as an attachment.
The receiver decrypts with its private key, verifies the signature against the sender's certificate, decompresses, and returns a signed receipt in the same HTTP response. A synchronous receipt is mandatory in the eDelivery AS4 profile: the sender waits for it immediately, not in a separate request.
A readable route: the endpoint is a string
redb.Route is Apache Camel for .NET: a route reads From → … → To, and an endpoint is a URI string. The AS4 connector adds two schemes, as4 (HTTP) and as4s (HTTPS), and they read like a sentence:
as4s://ap.partner.example/as4?connectionFactory=node&partner=acme # where we send to
as4:/as4/in?host=0.0.0.0&port=4090&connectionFactory=node # where we listen
The string states the intent up front: where we go, which port we listen on, which node and partner. There is a fluent builder too, and it compiles to the same URI, so an endpoint address can live in appsettings.json and move between environments without touching code.
services.AddRedbRoute(route =>
{
route.Services.AddRedbRouteAs4();
route.AddRouteBuilder<MyRoutes>();
});
AddRedbRouteAs4() registers the schemes. The receive side runs on the shared Kestrel host that every HTTP-based connector in the process uses: an HTTP, SOAP, AS2 and AS4 route in one worker never fight over a port, each one listens on its own path. Certificates, passwords and algorithms do not live in the URI, because the URI is the route key and ends up in logs, traces and the dashboard. Everything secret goes into a separate object.
The same integration can be written as Route-XML instead of code: endpoints in .route.xml files, the node and partners in <bean>. It is pleasant to write in VS Code with the redb-route-xml extension: completion and validation against the schema, a route tree, a step graph. The extension is not on the Marketplace; it ships as a file with every release. Download redb-route-xml-<version>.vsix from the releases page and install it through "Extensions → … → Install from VSIX" or with code --install-extension redb-route-xml-<version>.vsix (VS Code usually pulls in the base Red Hat XML extension on its own). Below, every C# example is paired with its XML equivalent, and full markup examples live in the repository: demos/XmlDemo and demos/SerialNumbersDemo/SerialNumbers.Xml.
The node and its partners: one object, not a scatter of parameters
In AS4 everything hangs off the agreement (the P-Mode): who, what, to whom, with which algorithms, whether a receipt is required. With us the agreement is a typed object in the registry. Our node (As4ConnectionFactory) knows our identity, our keys and the list of partners; each partner (As4Partner) is one agreement.
context.AddToRegistry("node", new As4ConnectionFactory
{
OurPartyId = "urn:oasis:names:tc:ebcore:partyid-type:unregistered:us",
ExternalHostName = "ap.us.example", // host in our eb:MessageId, not the machine name
SigningCertificate = ourPfx, // our key: signs outgoing messages and receipts
DecryptionCertificates = { ourPfx }, // our keys: decrypt what partners encrypt for us
Partners =
{
new As4Partner
{
Name = "acme",
PartyId = "urn:oasis:names:tc:ebcore:partyid-type:unregistered:acme",
Service = "urn:example:services:invoice",
Action = "Submit",
PartnerSigningCertificates = { acmeCer }, // verifies the partner's signature
PartnerEncryptionCertificate = acmeCer, // encrypts payloads for the partner
},
},
});
A route refers to the node by name (connectionFactory=node) and, when sending, to the partner (partner=acme). The receive endpoint does not name a partner: one receive URL accepts every partner of the node, and the connector tells the sender apart from the envelope itself. A certificate in the list is not "validity in general": a signature is accepted only from a partner certificate and only if it covers what the profile requires. Key rotation is a list of several certificates, not a stop in the exchange.
The same node and partner in markup (context.xml):
<context xmlns="urn:redb:route:1.0">
<bean name="acme" type="redb.Route.As4.As4Partner, redb.Route.As4">
<property key="Name" value="acme"/>
<property key="PartyId" value="urn:oasis:names:tc:ebcore:partyid-type:unregistered:acme"/>
<property key="Service" value="urn:example:services:invoice"/>
<property key="Action" value="Submit"/>
<property key="PartnerSigningCertificates">
<list>
<bean type="System.Security.Cryptography.X509Certificates.X509CertificateLoader, System.Security.Cryptography"
factoryMethod="LoadCertificateFromFile">
<constructorArg value="{{as4.certificates}}/acme.cer"/>
</bean>
</list>
</property>
<property key="PartnerEncryptionCertificate">
<bean type="System.Security.Cryptography.X509Certificates.X509CertificateLoader, System.Security.Cryptography"
factoryMethod="LoadCertificateFromFile">
<constructorArg value="{{as4.certificates}}/acme.cer"/>
</bean>
</property>
</bean>
<bean name="node-key"
type="System.Security.Cryptography.X509Certificates.X509CertificateLoader, System.Security.Cryptography"
factoryMethod="LoadPkcs12FromFile">
<constructorArg value="{{as4.certificates}}/us.pfx"/>
<constructorArg value="{{as4.password}}"/>
</bean>
<bean name="node" type="redb.Route.As4.As4ConnectionFactory, redb.Route.As4">
<property key="OurPartyId" value="urn:oasis:names:tc:ebcore:partyid-type:unregistered:us"/>
<property key="ExternalHostName" value="ap.us.example"/>
<property key="SigningCertificate" ref="node-key"/>
<property key="DecryptionCertificates"><list><ref bean="node-key"/></list></property>
<property key="Partners"><list><ref bean="acme"/></list></property>
</bean>
<bean name="as4-in" type="redb.Route.Processors.InMemoryIdempotentRepository, redb.Route"/>
</context>
Sending: compress, encrypt, sign, wait for the receipt
Sending is a To step. The body becomes a single attachment: a byte[] as is, a string in UTF-8, a Stream copied through.
using redb.Route.As4.Fluent;
From("direct://outbound")
.SetHeader(As4Headers.OriginalSender, "urn:example:c1") // four corners: the original sender
.SetHeader(As4Headers.FinalRecipient, "urn:example:c4") // and the final recipient
.To(As4.Send("https://ap.acme.example/as4")
.ConnectionFactory("node")
.Partner("acme"));
Behind that one To line: gzip, encryption with the partner's key, a signature with your key, the POST and the parsing of the response. The producer waits for the receipt and checks it: signed by a partner certificate and referencing exactly the digests we signed. The result lands on exchange.Out, so the route can decide:
Header on exchange.Out |
What it means |
|---|---|
redbAs4.receiptValid |
bool: the receipt arrived and verified, non-repudiation included |
redbAs4.receiptMessageId |
the eb:MessageId of the receipt itself |
redbAs4.messageId |
the eb:MessageId of the message we sent |
Exceptions instead of hand-parsing the response:
| Outcome | Exception |
|---|---|
| The partner answered with an ebMS error | As4ErrorSignalException (ErrorCode, Description, ErrorDetail) |
No receipt in the response, or no response within timeout |
As4ReceiptException (EBMS:0301) |
| A receipt with someone else's signature or different digests | As4ReceiptException (EBMS:0302) |
The same sending route in markup:
<!-- routes/outbox.route.xml -->
<routes xmlns="urn:redb:route:1.0">
<route id="as4-outbox">
<from uri="direct://outbound"/>
<setHeader name="redbAs4.property.originalSender" value="urn:example:c1"/>
<setHeader name="redbAs4.property.finalRecipient" value="urn:example:c4"/>
<to uri="as4s://ap.acme.example/as4?connectionFactory=node&partner=acme"/>
</route>
</routes>
Receiving: decrypt, verify, hand to the route, answer with a receipt
Receiving is a From step, which is to say a route source.
From(As4.Receive("/as4/in").Host("0.0.0.0").Port(4090)
.ConnectionFactory("node").IdempotentRepository("as4-in"))
.ValidateXsd(invoiceSchema) // validate the business document against its XSD
.To("direct://process-invoice");
The arriving envelope is decrypted, its signature verified, decompressed and matched to a partner, and what lands in the route is a clean business document with the right Message.ContentType (say application/xml or application/edi-x12), not a transport wrapper. The exchange metadata lives under redbAs4.*:
| Header | What it means |
|---|---|
redbAs4.partner |
the partner the message was matched to |
redbAs4.signatureValid / signerThumbprint |
the signature verified against the partner certificate; its thumbprint |
redbAs4.fromPartyId / toPartyId |
the parties of the message |
redbAs4.service / action |
the business operation from eb:CollaborationInfo |
redbAs4.messageId |
the eb:MessageId of the incoming message |
One detail matters here. The receipt goes back to the partner after the route has run. If your To failed and the transaction rolled back, there will be no successful receipt, because otherwise you would have acknowledged a document you did not store. A failed route becomes an ebMS error (EBMS:0004), and the sender retries the delivery.
The same receiving route in markup:
<!-- routes/inbox.route.xml -->
<routes xmlns="urn:redb:route:1.0">
<route id="as4-inbox">
<from uri="as4:/as4/in?host=0.0.0.0&port=4090&connectionFactory=node&idempotentRepository=as4-in"/>
<validateXsd file="invoice.xsd"/>
<to uri="direct://process-invoice"/>
</route>
</routes>
Reliability from the engine: duplicates and redelivery of the same message
The profile requires duplicate detection, reliable messaging (Reception Awareness) and retries. In redb.Route none of that is written again: the connector stands on the engine's existing machinery and invents no retry loop, no duplicate store and no counter of its own.
On receive, idempotentRepository is mandatory. The eb:MessageId of an incoming message is claimed only after every security check (a forged message cannot take the id of a real one), confirmed when the route succeeds and released when it fails. A duplicate arrives: the partner gets the receipt again, but the message is not delivered to the route a second time. The store comes in flavours, and the choice decides what survives a restart. InMemoryIdempotentRepository forgets everything on restart and cannot see a duplicate that arrives on another node behind a load balancer; for production there is a redb or SQL store. The deduplication window is the store's Ttl, and it must be longer than the sender's whole retry schedule.
On send, a retry is OnException. The producer sets redbAs4.messageId on the exchange before the first attempt and keeps the signed request on the exchange: a redelivery sends the same bytes, the same id and the same signature. That is not pedantry, it is what the protocol asks for: in AS4 a redelivery is the same message. The partner recognises the duplicate and answers with the receipt it stored for the first transmission (Domibus does exactly this), and its digests point at the very signature we resent.
OnException<As4ReceiptException>()
.MaximumRedeliveries(5)
.RedeliveryDelay(TimeSpan.FromSeconds(30))
.UseExponentialBackOff()
.Handled()
.To("direct://as4-undelivered"); // dead letter after the last attempt
The same handler in markup (a file-level section, applying to every route in the file):
<onException exceptions="redb.Route.As4.As4ReceiptException, redb.Route.As4"
handled="true" maximumRedeliveries="5" redeliveryDelay="00:00:30"
exponentialBackOff="true">
<to uri="direct://as4-undelivered"/>
</onException>
The engine's retry lives in memory: a restart loses the exchange it was retrying. For schedules measured in hours, hold the document in an outbox of your own (a redb object with the saved redbAs4.messageId) and resend what still has no receipt from a timer: route.
Streaming: huge documents are normal
In B2B you meet attachments of hundreds of megabytes, and holding them whole in memory is not an option. An outgoing attachment is written to the wire through the core's stream cache: a large payload passes through a temporary file, not through a byte[]. Compression and encryption are streamed (streamed AES-GCM on BouncyCastle), and an incoming attachment can be read as a stream (streamBody) rather than a buffer expanded into memory.
There are bounds, too: the request body size (maxRequestBodySize, 100 MB by default), the SOAP envelope size (maxEnvelopeCharacters, 1 MB; the payload rides as attachments and is not counted against it), and the partner's response size (maxResponseBodySize, 4 MB; a receipt is kilobytes). A forward-only stream that cannot be reread is read once: to retry a send, cache it in the route with .StreamCaching().
The crypto layer we had to write ourselves
This is where AS4 is genuinely hard, and why there is no mainstream .NET implementation. There was no built-in path, and not out of laziness: the API has specific gaps.
SignedXml in .NET cannot resolve cid: references to MIME attachments. So the signature is handled by hand: the connector parses ds:Reference and SignedInfo itself, applies the Attachment-Content-Signature-Transform and Exclusive C14N (through the public XmlDsigExcC14NTransform), and signs with RSA PKCS#1 v1.5.
EncryptedXml in .NET cannot do AES-GCM, and its DecryptKey supports only OAEP-SHA1, while the profile requires OAEP with MGF1-SHA256. So the session key is decrypted by hand, and the data is encrypted through System.Security.Cryptography.AesGcm by the rules of XML Encryption 1.1 (a 12-byte IV, a 16-byte tag, order "IV, ciphertext, tag").
Untrusted XML goes only through the core's SafeXml: a DTD on input is refused (SOAP 1.2 itself does not allow one), and the envelope is size-limited. Against signed-element substitution (signature wrapping) the defence is that duplicate wsu:Id values are rejected and every reference must cover eb:Messaging, soap:Body, each attachment and the wsu:Timestamp when present. Against replay, a wsu:Timestamp check by the WSS4J rules plus deduplication on messageId. The text of decryption and signature errors never leaves: the detail goes to the log only, no oracle. Passwords are marked [Sensitive] and redacted from logs and the dashboard.
The 1.16 profile: what we accept, what we reject
The connector implements the eDelivery AS4 1.16 profile and nothing beyond it. That is not a self-imposed limit, it is the reason it talks to real partners: the moment an implementation starts "guessing" at extensions, interop breaks.
| What | We accept | Otherwise |
|---|---|---|
| Signature | RSA-SHA256 | the configuration is refused at startup; on input, EBMS:0101 |
| Digest | SHA-256 | the same |
| Payload encryption | AES-128-GCM | at startup; on input, EBMS:0102 |
| Key transport | RSA-OAEP, MGF1-SHA256 | at startup; on input, EBMS:0102 |
| Canonicalization | Exclusive C14N | EBMS:0101 |
| Key reference | BST / IssuerSerial / KeyIdentifier (on input) | BST by default on send |
| An unsigned or unencrypted message | never | EBMS:0103 |
| A payload in the SOAP body | never (attachments only) | EBMS:0002 |
Some of what the specification calls optional is simply out of scope: Pull and asynchronous receipts. The common 1.16 profile does not require them either; it states plainly that a synchronous receipt is mandatory and an asynchronous one must not be used.
Interop is proven, not claimed
An AS4 connector that only talks to itself proves nothing. This one is tested against independent implementations, with no shared code, in both directions.
Against Holodeck B2B 8.1.1: both directions, across the whole profile matrix, with signatures, encryption, compression and each of the three key-reference forms. Against Apache Domibus 5.1 (the reference EU eDelivery access point, on a Harmony AP 2.6.2 stand): also both ways. We send, Domibus accepts (RECEIVED); Domibus sends, the route gets the document, and Domibus accepts our receipt (ACKNOWLEDGED).
Interop also surfaced things that are invisible without a live partner. Domibus's eDeliveryAS4Policy (Strict, without IncludeTimestamp) rejects wsu:Timestamp, so by default the connector sets no timestamp, and turns it on when the partner's policy asks. And on a redelivery Domibus returns the receipt it stored for the first transmission: had we rebuilt the message with a fresh signature, its digests would not have matched. That is why a redelivery resends the same bytes. This is exactly the class of problem the specification text does not contain and that only interop finds.
How it differs from a Java MSH or a gateway
AS4 in a .NET project used to be closed two ways: a separate Java MSH (a Message Service Handler, the AS4 term for an access point) or a commercial gateway. The difference is not whether they "can" do it, the protocol is one. The difference is where AS4 lives relative to your logic.
| Commercial gateway | Java MSH (Domibus/Holodeck) | redb.Route.As4 | |
|---|---|---|---|
| Process | a separate box | a separate JVM beside | your .NET process |
| Received document | in an inbox directory | in an inbox directory | a message in the route |
| How you get it | a poller job | a poller job | straight into the pipeline |
| Deploy | its own installation | its own installation | with your application |
| Observability | its own panel | JVM logs | the shared traces and statistics |
| Further processing | outside the gateway, by hand | outside the server, by hand | the same EIPs, in the same route |
| License | commercial | open | open, no key |
A separate MSH is justified when the AS4 contour is deliberately kept in an isolated zone, say a DMZ run by another team. When the document ends up in your .NET backend anyway, an intermediate process is one more hop, one more directory and one more component in the audit scheme.
Where it is used
AS4 is needed wherever a document carries an obligation and the channel is dictated by someone else. A few typical situations.
Peppol and e-invoicing. An electronic invoice on the Peppol network travels by AS4 between access points. If you build your own access point or receive invoices directly, AS4 becomes a route step rather than an external box: the invoice arrives, is parsed, validated against UBL/CII, and lands in the ERP.
EU government and legal documents (e-CODEX, e-Justice). Court and legal documents between national systems travel by eDelivery AS4 with strict signature and non-repudiation requirements. Here the legal weight of the document is the point of the channel.
Energy (ENTSOG). Requests, schedules and balancing documents between gas operators. The exchange is mandatory, the profile is strict, and the partners are other operators' nodes.
Transport and customs (eFTI). Electronic transport documents and customs papers. The same profile: signature, encryption, receipt.
Enterprise exchange with an EU public buyer. Tender, reporting and regulatory documents that European buyers and regulators require over eDelivery AS4.
Across all of them one thing is the same: the document must arrive reliably and with proof, and then be processed in your system. That seam is what the connector closes.
Honest limits
Nothing is free, so it is better to say plainly what is not here yet.
- Pull (the receiver fetches the message itself) and asynchronous receipts are not implemented. The common 1.16 profile does not require them, but if a specific partner wants Pull, this is not there yet.
- eDelivery AS4 2.0 (elliptic curves instead of RSA-OAEP) is not supported. The connector targets the 1.x line, common profile 1.16.
- Dynamic partner discovery (Peppol SMP/SML) is a separate, system-level task. The connector takes the address and the certificate from the agreement; where you get them is up to your integration.
- TLS against a partner's MSH has not been exercised live: both interop stands ran plain HTTP, while TLS and mutual TLS are covered by loopback handshakes. With a real partner it is worth checking separately.
- The engine's retry lives in memory, so schedules measured in hours need an outbox (which is a route pattern, not the connector).
FAQ
Do I need a Java MSH beside it? No. It is a native .NET connector: HttpClient, the shared Kestrel host and System.Security.Cryptography. It is both the sender and the receive access point.
AS4 or AS2? Whatever your partner requires. AS2 is S/MIME and MDN; AS4 is SOAP, WS-Security and an ebMS receipt. EU government and Peppol dictate AS4, while US retail and logistics are often AS2. We cover AS2 separately; both connectors live in one process and run side by side.
Where do certificates go? In As4ConnectionFactory and As4Partner, as X509Certificate2. Passwords are marked [Sensitive], and there are no secrets in the URI.
Can one worker carry HTTP, SOAP, AS2 and AS4? Yes. They receive on the shared Kestrel host, each route on its own path.
What if the partner answers with an error? The producer throws As4ErrorSignalException with the code and description; OnException decides whether to retry or move to a dead letter.
Synchronous or asynchronous receipt? Synchronous, in the same HTTP response. It is a requirement of the 1.16 profile, which is why transacted is not allowed on a send: you cannot defer a synchronous receipt to the end of a transaction.
Install
dotnet add package redb.Route.As4
The package is redb.Route.As4 on NuGet; the source and the full DSL reference are in the connector README. AS4 is one more transport in the redb.Route family, alongside Kafka, RabbitMQ, AS2, IBM MQ and the rest: the same From → … → To, the same EIPs, the same observability. The only difference is that the wire carries a SOAP envelope with ebMS headers, which a European access point is waiting for.
If this was useful, a ⭐ on GitHub helps others find it.
More of my writing: redbase.app/articles, and on dev.to.