Introduction
as2d is a production AS2 server: it exchanges EDI documents (or any payload) with your trading partners over HTTP using the AS2 protocol (RFC 4130, AS2 version 1.1 including RFC 5402 compression), with encryption, digital signatures, and signed delivery receipts (MDNs).
It is a single small container image, written in Rust on the OpenSSL crypto library, that runs the same way everywhere: a laptop, a Docker host, any Kubernetes cluster, or Azure Container Apps at scale-to-zero.
Who this book is for
- Managers / evaluators — start with Why as2d, the feature matrix, and interoperability.
- Operators — the Deployment chapters take
you from
docker runto Kubernetes and Azure, including license installation and monitoring. - Integrators / developers — the Integration chapters cover partnership configuration, certificates, MDN semantics, and product-specific notes for the AS2 software your partners run.
What AS2 is, in one paragraph
AS2 wraps a business document in S/MIME layers — optionally compressed, signed, and encrypted — and POSTs it to the partner’s HTTPS endpoint. The receiver unwraps it, verifies who sent it and that it was not altered, and returns a Message Disposition Notification (MDN): a signed receipt containing a cryptographic digest (the MIC) of what was received. When the sender’s own digest matches the receipt’s, both sides hold proof of delivery — the property that makes AS2 the standard for retail, logistics, pharma, and healthcare EDI (including Drummond-certified networks like Walmart’s and the DEA’s e-prescribing rules).
Why as2d
Verified against the software your partners actually run
Most AS2 products are tested against themselves. as2d’s test rig runs seven independent AS2 implementations in containers — spanning four crypto lineages (BouncyCastle/Java, Go, Python, Ruby/OpenSSL, JavaScript) — and exchanges live signed, encrypted, compressed messages with each of them in both directions on every release:
- phase2 / as2-lib (Java, BouncyCastle)
- OpenAS2 (Java, BouncyCastle)
- mendelson AS2 Community Edition (Java, BouncyCastle)
- pyas2lib / django-pyas2 (Python)
- Waarp AS2 (Go)
- the Ruby
as2gem - node-libas2 (JavaScript)
Where a counterpart deviates from the RFCs, that behavior is measured, documented, and — where safe — handled automatically or by a per-partner switch. See Interoperability and Working with specific products.
Built like infrastructure, not like a Java app from 2005
- One static binary in a small container. No JVM, no app server, cold starts in milliseconds, tens of megabytes of memory at idle.
- Durable by design. Outbound sends and asynchronous MDNs live in a persistent retry queue; kill the server mid-delivery and the exchange completes after restart. Replay protection deduplicates partner retries.
- Streaming-friendly. Large payloads are spooled, not held in RAM.
- Cloud-neutral core. Storage is pluggable: SQLite + filesystem for a zero-dependency single node, PostgreSQL for multi-replica, with optional Azure Blob payload storage and Azure Key Vault key storage. No cloud SDK ever touches the protocol engine.
Costs that match the workload
AS2 traffic is bursty. as2d’s smallest production profile is a single container with no external services at all; the Azure serverless profile scales to zero between messages and costs approximately nothing at idle. See Cost profiles.
Honest engineering
The protocol engine is built test-first with property-based and golden-vector tests, kept above 90 % line coverage, and every deviation a counterpart forced on us is recorded with the measurement that proved it. Security decisions (inbound security floors, receipt authentication, SSRF guards on receipt URLs, transient-only retries) are each pinned by regression tests.
Feature matrix
Protocol
| Capability | Support |
|---|---|
| AS2 version | 1.1 (RFC 4130 + RFC 5402 compression) |
| Transport | HTTP; TLS terminated at your ingress/load balancer |
| Directions | Full send and receive |
| Signing | RSA PKCS#1 v1.5 and RSASSA-PSS; MD5, SHA-1, SHA-224/256/384/512 |
| Encryption | 3DES, RC2, AES-128/192/256-CBC, AES-128/192/256-GCM (CAST5/IDEA present but excluded from support — see Algorithms) |
| Compression | ZLIB (RFC 3274), before or after signing; double-compression rejected |
| MDNs | Sync and async; signed and unsigned; requested per partner |
| MIC | Full RFC 4130 §7.3/§7.4 rules incl. micalg negotiation and 3851/5751 name normalization |
| Identifiers | AS2 quoted-name support both directions (§6.2) |
| Authentication | HTTP Basic to partner endpoints; pinned-certificate verification of partners |
Operations
| Capability | Support |
|---|---|
| Replay protection | Message-ID dedup store with TTL; duplicate deliveries re-answered idempotently |
| Retries | Durable queue, exponential backoff with jitter, restart-safe; transient failures only (connect errors, 408/429/5xx) |
| Message journal | Every business event persisted and emitted as structured logs |
| Large payloads | Spooled to disk, multi-GiB verified |
| Health | GET /healthz (reports degraded states in the body, always HTTP 200) |
| Logging | JSON (default) or text to stdout; level configurable; RUST_LOG override |
| Metrics | Optional Prometheus endpoint on a dedicated port (never on the partner-facing listener) |
| Shutdown | Graceful on SIGTERM/SIGINT — in-flight exchanges finish |
Storage backends
| Backend | Use case |
|---|---|
| SQLite + filesystem (default) | Single replica, zero external services |
| PostgreSQL | Multi-replica, scale-out, scale-to-zero (any Postgres: in-cluster, managed, free tiers) |
| Azure Blob Storage | Optional payload store override, composable with either |
| Azure Key Vault | Optional private-key store override (managed identity supported) |
Deployment targets
One image for all of them: Docker/compose, Kubernetes via the provided Helm chart (AKS, EKS, k3s, bare metal), Azure Container Apps (scale-to-zero bicep template provided), and any other container platform.
Interoperability
Interoperability is where AS2 projects succeed or die: two RFC-compliant implementations can still fail to reconcile receipts because the RFCs leave real choices open (what exactly the MIC digests, how AES-GCM is encoded, which algorithm-name spellings appear on the wire). as2d’s approach: measure against live implementations, never assume.
The verification matrix
Every release exchanges live traffic — signed, encrypted, compressed, with signed MDN reconciliation — with seven independent implementations, each running unmodified in its own container:
| Counterpart | Lineage | Both directions | Notes |
|---|---|---|---|
| phase2 / as2-lib | Java / BouncyCastle | ✔ (168-case matrix inbound) | incl. async MDN, PSS, GCM |
| OpenAS2 | Java / BouncyCastle | ✔ | incl. RC2, PSS, SHA-512 |
| mendelson AS2 CE | Java / BouncyCastle | ✔ | incl. its native GCM form |
| pyas2lib (django-pyas2) | Python | ✔ | incl. compression |
| Waarp AS2 | Go | ✔ | three CMS lineages agreeing |
Ruby as2 gem | Ruby / OpenSSL | ✔ | signed+encrypted |
| node-libas2 | JavaScript | ✔ | CBC/3DES |
Real-world compatibility built in
- BouncyCastle AES-GCM: the Java AS2 world encodes AES-GCM in a
non-conformant way (plain EnvelopedData with the auth tag appended)
that standard crypto libraries refuse. as2d receives that form
transparently — with the tag still enforced — and can send it per
partner (
bc_style_gcm) while defaulting to the RFC 5084 conformant encoding. - Algorithm-name spellings: RFC 5751 names by default, RFC 3851
(
sha256vssha-256) per partner, and in-the-wild aliases accepted on input. - Peppol: signer certificate embedded in signatures (default on).
- IBM Sterling: per-partner header-quoting and other switches.
- Per-partner content-transfer-encoding: binary (the wire default), base64, or quoted-printable outer encoding — different counterparts require different forms, and both are expressible per partnership.
Where a counterpart’s behavior makes strict receipt reconciliation impossible (for example, one product digests receipts without MIME headers on unsigned compressed messages), the exchange still completes and the divergence is documented in Working with specific products so your operators are never surprised by it in production.
Performance and feature comparison
Every number and every cell on this page is measured, not quoted from documentation: the same benchmark harness drives the same live exchange against each product running unmodified in its own container, and the feature matrix comes from the live interop verification runs. Tested versions are listed; newer releases may differ.
Benchmark
Scenario: the exchange profile every tested product supports — payload signed (RSA / SHA-256) and encrypted (AES-256-CBC), synchronous signed MDN, MIC reconciliation. One uniform sender (the as2d client) drives each receiver; latency is the full exchange as a partner experiences it: packaging, POST, the receiver’s decrypt/verify/store, MDN generation, and receipt verification.
Method: 100 messages per payload size after warmup, 4 concurrent senders, containerized receivers with their default configurations, all on the same host (a 12th-gen Intel i7-12700H laptop, NVMe SSD, Linux). Receivers were benchmarked one at a time. Results from 2026-08-26; raw data and the harness ship in the as2d source tree, so the run is reproducible.
| Receiver | 1 KiB msgs/s | 1 KiB p50 ms | 1 KiB p95 ms | 64 KiB msgs/s | 64 KiB p50 ms | 64 KiB p95 ms | 1 MiB msgs/s | 1 MiB p50 ms | 1 MiB p95 ms |
|---|---|---|---|---|---|---|---|---|---|
| as2d | 306 | 12.7 | 16.6 | 279 | 14.0 | 18.4 | 125 | 31.6 | 35.0 |
| phase2 | 235 | 14.6 | 30.4 | 166 | 23.5 | 29.7 | 27.8 | 143 | 153 |
| openas2 | 308 | 12.8 | 18.9 | 338 | 11.4 | 14.7 | 73.7 | 53.0 | 64.7 |
| mendelson | 37.3 | 101 | 181 | 33.2 | 118 | 190 | 5.55 | 711 | 1109 |
| pyas2 | 99.2 | 36.1 | 71.6 | 67.4 | 56.9 | 88.5 | 16.4 | 239 | 319 |
| waarp-as2 | 670 | 5.77 | 8.30 | 520 | 7.77 | 9.82 | 148 | 26.6 | 31.1 |
| ruby-as2 | 158 | 24.5 | 39.8 | 154 | 25.9 | 33.6 | 85.4 | 46.0 | 58.5 |
| node-libas2 | 71.9 | 55.6 | 63.2 | 53.6 | 73.1 | 85.4 | — | — | — |
Reading notes:
- What “durable” costs. as2d’s numbers include per-message durable state writes — replay-protection and journal entries are fsynced to disk before the receipt is acknowledged, because an acknowledged message must survive a crash. Products differ in what they persist before acknowledging, and this benchmark does not normalize that.
- as2d is nearly size-insensitive: median latency rises only 13 ms → 32 ms from 1 KiB to 1 MiB. It is the fastest receiver measured at every payload size except the Go implementation — while being the only product in the table whose numbers include fsyncing the payload, replay-protection state, and the journal before acknowledging (at 1 MiB it is still 4× phase2 and 22× mendelson CE).
- The crypto runtime matters as much as the product. as2d’s own
container numbers doubled just by moving its base image from OpenSSL
3.0 to 3.5 (3.0’s per-operation algorithm-fetch locking is a known
cost under concurrent CMS load that microbenchmarks like
openssl speeddo not show). If you run any AS2 product on an OpenSSL-3.0-era distribution, you are likely paying this too. - Waarp AS2 (Go) is the fastest receiver at small payloads; at 1 MiB it and as2d are effectively tied at the top.
- node-libas2 failed every 1 MiB exchange under load (it returned MDNs with no disposition field), hence the dashes — consistent with the library’s frozen 2020-era dependency set.
- Java products show their strength at small payloads once the JIT is warm (OpenAS2, phase2) but fall off steeply at 1 MiB.
Scaling under load
The same exchange at 16 concurrent senders: as2d’s durable writes batch across in-flight messages (group commit), so throughput scales near-linearly — 981 msg/s at 1 KiB, 800 msg/s at 64 KiB, and at 1 MiB it matches the fastest non-durable implementation measured (335 vs 342 msg/s) while fsyncing every payload before acknowledging it.
Feature matrix
As measured in the live interop rig (both directions, receipt reconciliation) — ✔ works as specified, ◐ works with caveats (see the per-product notes), ✘ not supported by that product.
| Capability | as2d | phase2 (as2-lib) | OpenAS2 4.10 | mendelson CE 1.1b69 | pyas2lib 1.4.4 | Waarp AS2 | Ruby as2 0.12 | node-libas2 0.8.2 |
|---|---|---|---|---|---|---|---|---|
| Signing SHA-2 family | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ |
| RSASSA-PSS signatures | ✔ | ✔ | ✔ | — | ✘ | — | — | — |
| AES-CBC / 3DES encryption | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ |
| AES-GCM (RFC 5084 conformant) | ✔ | ✘ rejects | — | — | — | ✘ rejects | — | — |
| AES-GCM (BC-compatible form) | ✔ receive + per-partner send | ✔ | — | ✔ (its native form) | — | ✘ rejects | — | — |
| ZLIB compression (RFC 5402) | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ✘ | ✘ |
| Sync signed MDN generation | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ◐ broken upstream (sends unsigned) |
| Async MDN delivery | ✔ | ✔ | ✔ | — | — | — | ✘ | ✘ |
| Honors requested receipt digest | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ✘ always SHA-256 | ✘ uses signature digest |
| Binary (raw) S/MIME bodies | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | ✘ base64 required |
| Enforces inbound security floor | ✔ (insufficient-message-security) | — | — | ✔ | — | — | ✔ (requires signed+encrypted, always) | — |
✘ is only used where the limitation was directly measured or is the product’s own documented constraint; — means the capability was not exercised against that product in the rig and is left unclaimed rather than assumed either way.
A note on fairness. These are open-source products measured under their own default configurations in containers; each of them may be tunable beyond what defaults deliver, and all of them interoperate correctly with as2d in the profiles above — that is the point of the interop rig. The benchmark answers one question honestly: what does the same exchange cost against each receiver, out of the box, on the same hardware.
Licensing
as2d is commercial software activated by a signed license file. The file is issued to you at purchase, is a few hundred bytes of text, and is installed alongside the server’s configuration — see Installing your license.
Two commercial models
Subscription — the license carries an expiry date. The server enforces it gently: after expiry there is a 14-day grace period during which everything keeps working while the logs, health endpoint, and metrics warn loudly; only after grace does the server stop accepting AS2 traffic (partners receive a clean, retryable HTTP 503 — messages are not lost, their retry mechanisms redeliver once you renew). Renewing is a file update; no restart is needed on Kubernetes.
Perpetual — the license never stops a running server. Instead it carries a maintenance-until date that gates which releases it activates: any build released on or before that date runs forever; a newer build asks you to renew maintenance first. You keep what you bought, renewals buy upgrades.
What a license covers
Licenses encode entitlements, so pricing can match usage:
- send and/or receive
- asynchronous MDNs
- the set of encryption algorithms
- messages per day (per running instance)
- maximum number of trading partnerships
If your configuration ever exceeds the license (say, an eleventh partnership on a ten-partnership license), the server tells you precisely what and why at startup — never by failing a partner’s message at 2 am.
What enforcement never does
- It never blocks receipts for messages already sent — in-flight exchanges always complete.
- It never drops queued deliveries — an expired license pauses outbound work, it does not discard it.
- It never phones home. Validation is a local cryptographic signature check against a key built into the software; as2d makes no network calls for licensing.
Security posture
Crypto core
All CMS/S-MIME cryptography is performed by libcrypto (OpenSSL 3) —
the most widely deployed and audited crypto library in existence — through
Rust bindings. The protocol engine itself is memory-safe Rust with
unsafe code forbidden in the core protocol crate. Legacy ciphers (RC2)
load through OpenSSL’s legacy provider, and the server round-trips RC2
through it at startup, in-process, before accepting traffic.
The production container is distroless: no shell, no package manager, no coreutils — the only executable in the image is as2d itself, alongside the OpenSSL shared libraries and the CA bundle (~50 MB total). There is nothing for an attacker to “live off the land” with, and vulnerability scanners have almost nothing to flag.
Enforced invariants
Each of these is protected by regression tests:
- Inbound security floor. If a partnership declares signing and/or
encryption, an inbound message missing those layers is rejected with
insufficient-message-security— a partner downgrade (or an attacker stripping layers) cannot slip plaintext through. Per-layer opt-outs exist for migration scenarios and are explicit configuration. - Receipt authentication. Asynchronous MDNs are verified against the pinned partner certificate from your keystore — not whatever certificate happens to be embedded in the receipt. A rejected or mismatched receipt cannot consume delivery state.
- SSRF guard. A receipt-delivery URL supplied by an unknown or
unauthenticated party is never honored, and only
http(s)URLs are ever delivered to — the receipt channel cannot be turned into an arbitrary-POST primitive against your network. - Certificate validity windows are enforced when signing and when verifying against the trust anchor.
- Replay protection. Message-IDs are remembered (configurable TTL); replays are answered idempotently without reprocessing.
- Retries are transient-only. Permanent HTTP failures (4xx other than 408/429) never loop.
Key handling
- Private keys are held in memory in zeroize-on-drop buffers.
- Keys load from PEM files you mount, or from Azure Key Vault using a managed identity — in that profile no private key ever touches disk.
- Partner trust is pinned: you install each partner’s certificate; there is no ambient CA trust for partner identity. (Verification against in-band certificates is available per partner for counterparts that rotate certificates that way, and is documented as the weaker mode.)
Network surface
- One partner-facing HTTP listener (
/as2,/as2/mdn,/healthz) — TLS terminates at your ingress. - Metrics, when enabled, are served on a separate port that you never expose to partners.
- No outbound connections except deliveries to configured partner URLs (and your configured storage backends). No telemetry, no license callbacks.
Quickstart (Docker)
A single container, a config file, a partnerships file, your keys, and your license. Working AS2 endpoint in five minutes.
1. Lay out a config directory
as2d/
├── config.toml
├── partnerships.toml
├── license.lic # issued to you at purchase
└── keys/
├── me.key.pem # your private key
├── me.cer.pem # your certificate
└── partner.cer.pem # each partner's certificate
config.toml:
listen = "0.0.0.0:8080"
[storage]
kind = "local"
data_dir = "/data"
partnerships_file = "/config/partnerships.toml"
keys_dir = "/keys"
[license]
path = "/config/license.lic"
[limits]
max_body_mb = 256
decompress_limit_mb = 512
partnerships.toml (one inbound + outbound partner; see
Partnerships for every option):
[[partnership]]
id = "acme-inbound"
local_id = "MYCOMPANY"
remote_id = "ACME"
cert_aliases = { local = "me", remote = "partner" }
[partnership.security]
sign = "sha-256"
encrypt = "aes256-cbc"
[[partnership]]
id = "acme-outbound"
local_id = "MYCOMPANY"
remote_id = "ACME"
remote_url = "https://as2.acme.example/as2"
cert_aliases = { local = "me", remote = "partner" }
[partnership.security]
sign = "sha-256"
encrypt = "aes256-cbc"
[partnership.mdn]
request = true
signed_micalg = "sha-256"
2. Run
docker run -d --name as2d \
-p 8080:8080 \
-v $PWD/as2d:/config:ro \
-v $PWD/as2d/keys:/keys:ro \
-v as2d-data:/data \
-e AS2D_CONFIG=/config/config.toml \
<registry>/as2d:<tag>
3. Check
curl http://localhost:8080/healthz
# → ok
If it prints degraded: unlicensed, the license file was not found — the
server is alive but refuses AS2 traffic until it is; see
Installing your license.
The startup log also tells you: which license is loaded and for how long, any certificate expiring within 30 days, and any partnership configured with an algorithm known to be problematic with certain partners.
4. Expose
Put your TLS-terminating reverse proxy (nginx, Caddy, a cloud load
balancer) in front of port 8080 and give partners the resulting URL —
messages POST to /as2 (or /), asynchronous receipts to /as2/mdn.
Kubernetes (Helm)
The provided Helm chart runs as2d on any conformant cluster — AKS, EKS, GKE, k3s, bare metal. It has been verified end-to-end on a live cluster in both its storage profiles.
Install
# Your key material (aliases resolve as {alias}.key.pem / {alias}.cer.pem):
kubectl create secret generic as2d-keys \
--from-file=me.key.pem --from-file=me.cer.pem --from-file=partner.cer.pem
# Your license (key name must be license.lic):
kubectl create secret generic as2d-license --from-file=license.lic
helm install as2d ./as2d \
--set-file partnerships=partnerships.toml \
--set licenseSecret=as2d-license \
--set image.repository=<registry>/as2d --set image.tag=<tag>
The chart refuses to install without a license Secret unless you
explicitly pass --set allowUnlicensed=true (which deploys in a degraded
state — health answers, AS2 traffic 503s — useful for platform smoke
tests before purchase completes).
Storage profiles
| Profile | Values | External services |
|---|---|---|
| Single replica (default) | storage.kind=local | none — SQLite + a PVC |
| Scale-out, self-hosted | storage.kind=postgres postgres.internal=true replicaCount=N | an in-cluster Postgres the chart deploys for you |
| Scale-out, managed DB | storage.kind=postgres postgres.url=... | any managed PostgreSQL |
The chart enforces the safety rule for you: more than one replica requires the Postgres backend (local SQLite state is single-replica by design), with payloads stored in the database so every replica sees them.
Azure-native storage on AKS
Payloads in Blob Storage and/or private keys in Key Vault compose with
either profile via extraConfig:
extraConfig: |
[storage.blob]
account = "myaccount"
account_key = "..."
container = "as2-payloads"
endpoint = "https://myaccount.blob.core.windows.net"
[storage.key_vault]
vault_url = "https://myvault.vault.azure.net"
Operational notes
- License renewal without restart: update the Secret in place —
Kubernetes updates the mounted file and as2d re-reads it within the hour. A corrupted update is harmless: the server keeps the last good license and logs the problem.kubectl create secret generic as2d-license --from-file=license.lic \ --dry-run=client -o yaml | kubectl apply -f - - Readiness stays 200 even when the license degrades — deliberately.
An expired deployment should tell partners “503, retry later” at the
protocol level, not vanish from the load balancer (which partners see
as an outage with no diagnosis). Monitor the
as2_license_stategauge or the/healthzbody instead of tightening the probe. - Metrics:
--set metrics.enabled=trueopens the dedicated metrics port with Prometheus scrape annotations; it is never exposed on the partner-facing Service. - TLS terminates at your ingress controller, per the container-first design.
- With
postgres.internal=truethe as2d pods may restart a few times on first install while Postgres initializes — connecting at boot is deliberate (a bad database URL should fail loudly, not at the first message). They settle once Postgres is ready.
Azure Container Apps
The serverless profile: scale-to-zero containers. Deliberately not Azure Functions — AS2 needs long uploads and same-connection synchronous receipts, which FaaS models handle poorly. The same as2d image runs here unchanged; a ready-made bicep template deploys the whole environment (Container Apps environment, Log Analytics, ingress, secrets).
Deploy
az group create -n as2d-rg -l westeurope
az deployment group create -g as2d-rg -f aca.bicep \
-p image=<registry>/as2d:<tag> \
-p postgresUrl='postgres://...?sslmode=require' \
-p partnershipsToml=@partnerships.toml \
-p licenseContent=@license.lic \
-p keyFiles='{"files":[
{"name":"me.key.pem","value":"-----BEGIN PRIVATE KEY-----..."},
{"name":"me.cer.pem","value":"-----BEGIN CERTIFICATE-----..."},
{"name":"partner.cer.pem","value":"-----BEGIN CERTIFICATE-----..."}]}'
TLS is terminated by the Container Apps ingress; the output of the deployment is your partner-facing HTTPS URL.
Choices baked into the template
- State must be PostgreSQL — instances are ephemeral at scale-to-zero. Azure Database for PostgreSQL, or a managed free tier for minimal cost.
- Payloads and keys default to Postgres + secret-mounted PEM files.
For Azure-native storage pass
extraConfigwith[storage.blob]/[storage.key_vault]; the app has a system-assigned managed identity — grant it Key Vault Secrets User and the keystore authenticates with no credentials in config. - Scale-to-zero caveat: the retry worker lives inside as2d, so queued
retries and asynchronous MDNs only progress while a replica is awake.
Set
minReplicas=1if partners depend on async receipts;minReplicas=0is fine for synchronous-only traffic (cold start of the static binary is comfortably inside partner HTTP timeouts). - License renewal: Container Apps secret volumes do not reliably
update in place — after renewing, re-run the deployment (or restart the
revision) with the new
licenseContent. The 14-day grace window exists precisely so this is never an emergency.
Cost
Consumption plan with minReplicas=0 plus a free-tier Postgres is
approximately $0 at idle — you pay per request-second of compute.
See Cost profiles.
Cost profiles
The free single-node profile is a first-class product feature, not a demo mode — the same binary, the same protocol engine, the same tests. Pick the profile that matches your volume and availability needs; you can migrate between them by changing configuration only.
| Profile | Infrastructure | Typical monthly infra cost |
|---|---|---|
| Single node | one container, SQLite + local disk | $0 beyond the VM/host you already have |
| Kubernetes, single replica | Helm chart, storage.kind=local, one PVC | $0 beyond cluster compute |
| Kubernetes, scale-out | Helm chart + in-cluster Postgres container | $0 beyond cluster compute |
| Kubernetes, managed DB | Helm chart + managed PostgreSQL | free tier (Neon/Supabase) → ~$15 for small managed instances |
| Azure serverless | Container Apps consumption plan, minReplicas=0, free-tier Postgres | ≈ $0 at idle; pay per request-second |
Guidance:
- Availability: single-replica profiles restart in seconds and the durable queue means nothing is lost — for most EDI volumes that is plenty. Choose scale-out when you need zero-downtime deploys or more than one node’s throughput.
- Async MDNs at scale-to-zero: keep one replica warm
(
minReplicas=1) if partners rely on asynchronous receipts, since queued deliveries only progress while an instance is running. - Free-tier databases (Neon, Supabase) are genuinely workable for low
volumes; TLS is honored via the connection string’s
sslmode. Move to a paid tier when your retention or throughput outgrows them — it is a connection-string change.
Installing your license
Your license is a short signed text file (license.lic). as2d validates
its signature against a key built into the binary — locally, instantly,
with no network access.
Where it goes
The config file points at it:
[license]
path = "/config/license.lic" # default: /etc/as2d/license.lic
- Docker: mount it read-only next to your config (see Quickstart).
- Kubernetes: a Secret named by the chart’s
licenseSecretvalue, keylicense.lic(see Kubernetes). - Azure Container Apps: the
licenseContentdeployment parameter.
What happens in each state
| State | Server behavior |
|---|---|
| Valid | Normal operation; startup log shows customer, type, days remaining |
| Expiring within 30 days | Warning in logs and as2_license_days_remaining gauge counts down |
| Expired, within 14-day grace | Everything still works; loud warnings; /healthz body says degraded: license in grace, N day(s) left |
| Expired past grace | Inbound messages answered 503 (partners’ retry logic redelivers after renewal); outbound deliveries pause but stay queued; /healthz body degraded: license expired |
| File missing | Server boots “unlicensed”: health answers, AS2 traffic 503s until the file appears |
| File present but invalid | Server refuses to start, with the exact reason on stderr/logs |
/healthz always returns HTTP 200 — degraded states are reported in the
response body and the metrics gauges, so load balancers keep routing and
partners get diagnosable protocol-level errors instead of connection
failures.
Renewal
Replace the file; as2d re-reads it hourly. On Kubernetes that means updating the Secret in place — no restart, no pod roll. If a bad file lands (truncated upload, wrong file), the server keeps the last good license and logs the error; a botched renewal can never take a healthy server down.
Entitlement errors at startup
If your configuration exceeds the license — an algorithm not covered, a partnership requesting async MDNs on a license without them, more partnerships than allowed, outbound partners on a receive-only license — as2d refuses to start and names the exact partnership and entitlement. Fix the configuration or upgrade the license; the point of failing at startup is that a partner’s message at 2 am is never the first sign.
Logging and metrics
Logs
Structured logs go to stdout — the collection point every platform
scrapes (Log Analytics, CloudWatch, fluentbit/EKS, journald, docker logs). JSON by default; text for humans at a console:
[observability]
log_format = "json" # or "text"
log_level = "info" # RUST_LOG env var overrides when set
Every business event in a message’s life is emitted as a structured log
line (target as2_journal) and persisted in the message journal, so
your dashboards and the durable audit trail always agree. Event kinds
include: received, stored, rejected, duplicate, mdn-received,
mdn-rejected, mdn-mic-mismatch, delivered, retry-scheduled,
gave-up, receipt-request-failed, store-failed.
Startup logs additionally surface the loaded license (customer, type, days remaining), certificates expiring within 30 days, and any partnership configured with an algorithm known to be problematic with BouncyCastle-based partners.
Metrics (optional)
Prometheus metrics are opt-in and served on a dedicated listener that never rides the partner-facing ingress:
[observability.metrics]
enabled = true
listen = "0.0.0.0:9090"
| Metric | Labels | Meaning |
|---|---|---|
as2_journal_events_total | kind, partnership | business events (same taxonomy as the journal) |
as2_http_requests_total | route, status | partner-facing HTTP requests |
as2_http_request_duration_seconds | route | request latency histogram |
as2_license_state | — | 0 valid · 1 grace · 2 expired · 3 unlicensed · 4 build-not-covered |
as2_license_days_remaining | — | days to subscription expiry (−1 when not applicable) |
Alerting suggestions: page on as2_license_state > 0, on
as2_journal_events_total{kind="gave-up"} increases, and on
kind="mdn-mic-mismatch".
The Helm chart adds Prometheus scrape annotations automatically when metrics are enabled. Clients who do not want metrics simply leave them disabled — the metrics listener then does not exist at all.
Partnerships
A partnership is one directed relationship between an AS2 identity of
yours (local_id) and one of a partner’s (remote_id). All of them live
in partnerships.toml; one server can host many local identities.
Anatomy
[[partnership]]
id = "acme-outbound" # unique config key (appears in logs/metrics)
local_id = "MYCOMPANY" # our AS2-From when sending / AS2-To inbound
remote_id = "ACME" # the partner's AS2 name
remote_url = "https://as2.acme.example/as2" # present = outbound-capable
cert_aliases = { local = "me", remote = "acme" }
[partnership.security]
sign = "sha-256" # omit for unsigned
encrypt = "aes256-cbc" # omit for unencrypted
compress = "before-signing" # or "after-signing"; omit for none
content_transfer_encoding = "binary" # "binary" (default) | "base64" | "quoted-printable"
[partnership.mdn]
request = true # ask for a receipt when we send
signed_micalg = "sha-256" # ask for a SIGNED receipt with this digest
# async_url = "https://my.example/as2/mdn" # asynchronous receipt delivery
# [partnership.http_auth] # HTTP Basic for the partner's endpoint
# username = "u"
# password = "p"
# [partnership.interop] # per-partner compatibility switches, see below
How inbound routing works
An arriving message’s AS2-From/AS2-To headers select the partnership
(quoted AS2 names are handled per RFC 4130 §6.2). Unknown pairs are
rejected with an authentication-failed disposition — there is no
“accept anyone” mode.
The security policy is a floor, not a hint
Declaring sign and/or encrypt does two things:
- Outbound: those layers are applied when sending.
- Inbound: messages from this partner must carry those layers or
they are rejected with
insufficient-message-security. A partner (or an attacker) cannot downgrade the exchange to plaintext.
Migration escape hatches (interop.disable_decrypt,
interop.disable_verify, interop.disable_decompress) opt out per
layer, explicitly, per partner.
Onboarding gotcha — mirror the security attributes. In the BouncyCastle-derived AS2 world (and in as2d, for compatibility with it), whether MIME headers are included in the receipt digest is driven by each side’s own partnership attributes, not by the message. If you declare compression and the partner’s side does not, both implementations can be correct and the receipt MICs will still disagree. When receipts mismatch, compare partnership attributes with your partner first.
Interop switches
All default to sensible values; set them per partner when a counterpart needs it (see Working with specific products):
| Flag | Default | Purpose |
|---|---|---|
include_cert_in_signature | true | embed our certificate in signatures (Peppol requires it) |
rfc3851_micalgs | false | use sha256-style spellings instead of sha-256 |
quote_header_values | false | quote outbound header values (some IBM Sterling setups) |
block_error_mdn | false | never send negative receipts to this partner |
bc_style_gcm | false | send AES-GCM the BouncyCastle way (Java AS2 partners) |
force_decrypt / force_verify | false | attempt the layer even when the content-type does not announce it |
disable_decrypt / disable_verify / disable_decompress | false | skip a layer (weakens the floor; migration only) |
verify_use_cert_in_body_part | false | trust the certificate embedded in the message instead of the pinned one (weaker; for partners that rotate certificates in-band) |
The full field list with types is in the partnerships.toml reference.
Certificates and keys
What you exchange with a partner
AS2 trust is pairwise and pinned: you give the partner your certificate, they give you theirs, and each side installs the other’s. There is no CA hierarchy involved in partner identity (self-signed certificates are the norm in AS2).
Your certificate/key is used to sign outgoing messages and decrypt incoming ones; the partner’s certificate verifies their signatures and encrypts messages to them.
The keystore
Keys are looked up by alias — the names you chose in each
partnership’s cert_aliases:
cert_aliases = { local = "me", remote = "acme" }
PEM directory (default)
keys_dir holds PEM files named by alias:
keys/
├── me.key.pem # our private key (PKCS#8)
├── me.cer.pem # our certificate
└── acme.cer.pem # partner certificate (no key)
Generate a self-signed identity:
openssl req -x509 -newkey rsa:3072 -sha256 -days 1095 -nodes \
-keyout me.key.pem -out me.cer.pem -subj "/CN=MYCOMPANY AS2"
Convert a partner’s .p7b/.cer/.pfx to PEM with the usual
openssl pkcs7 -print_certs / openssl x509 -inform der /
openssl pkcs12 -legacy commands.
Azure Key Vault (optional)
With [storage.key_vault] configured, aliases resolve to Key Vault
secrets holding the PEM content ({alias}-key and {alias}-cert,
alias sanitized to Key Vault’s [A-Za-z0-9-] name alphabet), and
authentication uses the platform’s managed identity — no key material on
disk, no credentials in config. Entries are cached with a TTL, so a
rotated secret takes effect without restart.
Validity and rotation
- Certificate validity windows are enforced: as2d will not sign with an expired or not-yet-valid certificate, and pinned-cert verification checks the window too.
- Every certificate within 30 days of expiry is called out at startup, and expired ones are flagged as errors — certificate expiry is the single most common cause of AS2 outages, so as2d makes it loud and predictable.
- To rotate: install your new certificate alongside the old (a second partnership/alias during the overlap window is the cleanest pattern), send the new public certificate to partners, then retire the old alias.
MDN receipts
The MDN (Message Disposition Notification) is AS2’s proof of delivery: a
structured receipt carrying the disposition (processed, or an error)
and the Received-Content-MIC — a digest of what the receiver actually
got. When it matches the sender’s own digest, the loop is closed.
Requesting receipts (outbound)
[partnership.mdn]
request = true # ask for a receipt
signed_micalg = "sha-256" # ask for a SIGNED receipt with this digest
# async_url = "https://my.example/as2/mdn" # deliver asynchronously
- Synchronous (no
async_url): the receipt comes back on the same HTTP connection as the send. Simplest; use it unless payloads are large enough that partners time out generating receipts inline. - Asynchronous: the partner answers the POST immediately and later
delivers the receipt to your
async_url. as2d correlates it by Original-Message-ID, verifies the signature against the pinned partner certificate, checks the MIC, and journals the outcome. Receipts that fail authentication or match no pending message are rejected without consuming state.
Outbound sends and their receipt state live in the durable queue — a crash or restart between send and receipt loses nothing.
Serving receipt requests (inbound)
as2d honors what the partner’s Disposition-Notification-Options asks
for: signed receipts with any supported digest, unsigned receipts, sync
or async (async delivery goes through the durable retry queue, so an
unreachable partner URL is retried with backoff).
Receipt requests that cannot be honored are answered the way RFC 4130
requires — an explicit failed/Failure receipt rather than a silently
different one — for example a request for a signature protocol or digest
we do not support, or an async receipt request on a deployment whose
license does not include async MDNs.
Security guards on the receipt channel:
- A
Receipt-Delivery-OptionURL from an unknown partner is never honored (the error receipt goes back synchronously) — otherwise an unauthenticated request could aim durable, retried POSTs at any URL. - Only
http:/https:receipt URLs are ever delivered to.
When MICs don’t match
A MIC mismatch on an otherwise successful exchange almost always means the two sides disagree about what to digest, not that data was corrupted. Check, in order:
- Mirrored security attributes — see the onboarding gotcha in Partnerships: both sides must declare the same sign/encrypt/compress attributes or the headers-in-digest decision diverges.
- The counterpart’s known quirks — several products digest something slightly different in specific corners (documented per product in Working with specific products).
- The requested micalg — some products ignore the requested receipt digest and use their default or the signature digest; requesting the digest the partner will actually use makes reconciliation exact.
as2d journals mdn-mic-mismatch distinctly so these are visible and
alertable rather than silently logged.
Working with specific products
Everything below was measured against the live product in as2d’s interop rig — not inferred from documentation. Version numbers are the ones tested; newer versions usually behave the same but verify when in doubt.
Java / BouncyCastle family (phase2 · as2-lib · OpenAS2 · mendelson)
The largest AS2 population. Shared traits, handled automatically:
- AES-GCM: they encode GCM non-conformantly (EnvelopedData with the
auth tag appended, instead of RFC 5084 AuthEnvelopedData). as2d
receives their form transparently. To send GCM to them, set
interop.bc_style_gcm = trueon the partnership — the default conformant encoding is unreadable to them. - CAST5 / IDEA are unusable with this family in either direction (incompatible parameter encodings between BouncyCastle and OpenSSL). as2d warns at startup if a partnership configures them; use 3DES or AES-CBC instead. These two ciphers are excluded from product support.
- Signed messages digest receipts over the signed content; unsigned messages over the decompressed payload — as2d follows the same split, so MICs reconcile.
OpenAS2 (tested: 4.10.0)
- On unsigned + compressed messages OpenAS2 digests the receipt without MIME headers (following the RFC 4130 unsigned rule) while RFC 5402-followers (as2d, phase2) include them. The exchange works; the receipt MIC cannot reconcile. Avoid unsigned+compressed with OpenAS2 — signing fixes it (and is what you want anyway).
- OpenAS2 signs receipts without embedding its certificate — fine for as2d, which authenticates receipts against your pinned copy of their certificate.
- Everything else is clean both directions, including RSASSA-PSS, RC2, SHA-512, and compression before/after signing.
mendelson AS2 (tested: Community Edition 1.1b69)
- Enforces its configured partner security as an inbound floor, exactly like as2d — make sure both sides’ sign/encrypt settings mirror.
- Sends AES-GCM in the BouncyCastle form; as2d receives it fine.
- Accepts as2d’s signed receipts in all tested combinations.
pyas2lib / django-pyas2 (tested: pyas2lib 1.4.4)
- Interops cleanly both directions including compression and MIC reconciliation.
- Spells digest names without hyphens (
sha256); as2d accepts these automatically. - Normalizes line endings of text payloads to CRLF before signing — if byte-exactness of text documents matters downstream, be aware the delivered payload is CRLF-normalized. (Binary payloads are unaffected.)
Waarp AS2 (Go; tested at a 2025 development snapshot)
- Signed, encrypted (AES-CBC/3DES), and compressed exchanges are clean both directions with receipt reconciliation.
- Rejects both AES-GCM encodings at the tested snapshot — use AES-CBC with this partner.
Ruby as2 gem (tested: 0.12.0)
- Requires inbound messages to be signed and encrypted; supports synchronous signed receipts only; no compression, no async.
- Expects the raw (binary) S/MIME body — as2d’s default outer content-transfer-encoding, so it just works.
- Ignores the requested receipt digest and always uses SHA-256: request
signed_micalg = "sha-256"so reconciliation is exact.
node-libas2 (tested: 0.8.2)
- Cannot parse a binary S/MIME body: set
content_transfer_encoding = "base64"on the partnership. - Its generated signed receipts have an upstream signature bug — configure it to send unsigned receipts (it verifies as2d’s signed receipts correctly).
- Ignores the requested receipt digest and digests with the actual
signature algorithm (the RFC 4130 §7.4.3 rule): request a
signed_micalgmatching your signing digest. - CBC/3DES only; no compression; no async delivery.
IBM Sterling B2B Integrator
- Some deployments require quoted header values: set
interop.quote_header_values = true. - If a specific Sterling deployment rejects otherwise-valid exchanges, contact support with the negative MDN text — Sterling installations vary widely in their strictness settings.
Peppol networks
- Peppol requires the signer certificate embedded in signatures — as2d’s
default (
include_cert_in_signature = true).
Troubleshooting
Start with the logs: every message’s journey is journaled (received →
stored/rejected, mdn-received, delivered, retry-scheduled, …)
with the partnership id and message id on every line.
The partner’s message is rejected
The disposition in our negative receipt (and the rejected journal
entry) says why:
| Disposition | Meaning | Usual cause |
|---|---|---|
authentication-failed | no partnership matched AS2-From/AS2-To | identifier typo, case mismatch, or the partnership is missing — check both sides’ AS2 names character-for-character |
insufficient-message-security | message lacked a layer the partnership requires | partner sent unsigned/unencrypted; either they misconfigured, or your security floor is stricter than agreed |
decryption-failed | could not decrypt | partner encrypted to the wrong (old?) certificate; or an incompatible cipher (CAST5/IDEA with Java partners — use 3DES/AES) |
integrity-check-failed | signature did not verify | wrong partner certificate installed on our side, or the partner rotated certificates |
decompression-failed | bad or doubled compression | double compression is rejected by design |
unsupported format / unsupported MIC-algorithms | receipt request we cannot honor | partner asked for a signature protocol/digest outside the supported set |
unsupported receipt-delivery-option | async receipt requested but not available on this deployment | deployment/licensing does not include async MDNs |
error-storing-transaction | storage backend failed | check the storage backend’s health; the partner will retry |
Our send fails
- HTTP errors: 4xx (except 408/429) are treated as permanent — check
URL, HTTP auth, and that the partner has your partnership configured.
5xx/408/429/connection errors retry with exponential backoff; watch
retry-scheduledand the finaldeliveredorgave-up. gave-upjournal entries carry the last error text and attempt count.
Receipts mismatch (mdn-mic-mismatch)
Work through the checklist in MDN receipts — it is almost always non-mirrored security attributes or a counterpart quirk listed in Working with specific products.
The server answers 503 to partners
Check /healthz:
degraded: unlicensed/degraded: license expired— see Installing your license.- Body
okbut 503s under load — you may have hit a configured daily message quota; the 503 carries aRetry-Afterheader and partners’ retry logic rides it out.
The server won’t start
The error printed at startup is specific by design:
- license invalid → the exact verification failure
- configuration exceeds entitlements → the partnership and entitlement named
- storage unreachable → connect errors surface at boot (deliberately — fail at boot, not at the first message)
- a certificate expired → named alias and dates
Getting help
Include: the journal lines for one affected message id, the negative MDN text (if any), your partnership definition (redact keys/passwords), and both sides’ declared security attributes. That is nearly always enough to diagnose an interop issue on the first round-trip.
config.toml
The server reads one TOML file; its path comes from the AS2D_CONFIG
environment variable (default /etc/as2d/config.toml).
listen = "0.0.0.0:8080" # partner-facing listener
[storage]
kind = "local" # "local" | "postgres"
data_dir = "/data" # local: payloads + state.db; postgres: only used when payloads = "filesystem"
partnerships_file = "/config/partnerships.toml"
keys_dir = "/keys" # PEM keystore directory
# Required when kind = "postgres":
# [storage.postgres]
# url = "postgres://user:pass@host:5432/db?sslmode=require"
# payloads = "database" # "database" (multi-replica-safe) | "filesystem"
# Optional overrides, composable with either kind:
# [storage.blob] # payloads in Azure Blob Storage
# account = "myaccount"
# account_key = "..."
# container = "as2-payloads"
# endpoint = "https://myaccount.blob.core.windows.net"
#
# [storage.key_vault] # keys/certs from Azure Key Vault
# vault_url = "https://myvault.vault.azure.net"
# (auth: managed identity by default)
[limits]
max_body_mb = 256 # maximum accepted HTTP body
decompress_limit_mb = 512 # zip-bomb ceiling for decompression
[license]
path = "/etc/as2d/license.lic" # the signed license file
[observability]
log_format = "json" # "json" | "text"
log_level = "info" # RUST_LOG env var overrides
[observability.metrics]
enabled = false # opt-in Prometheus
listen = "0.0.0.0:9090" # DEDICATED listener — never expose to partners
Field notes:
listen— TLS is not terminated here; put your ingress/load balancer in front. Routes served:POST /andPOST /as2(messages, with receipts auto-detected),POST /as2/mdn(asynchronous receipts),GET /healthz.storage.kind = "local"— SQLite + filesystem underdata_dir. Single replica only; zero external services.storage.kind = "postgres"— all state shared; required for multiple replicas or scale-to-zero. Any PostgreSQL works; TLS honored viasslmodein the URL. Withpayloads = "filesystem"payload bytes stay on local disk (single replica or shared volume only).- Overrides —
[storage.blob]and[storage.key_vault]replace just the payload store / keystore and compose with eitherkind. [license].path— a missing file boots the server in the degraded unlicensed state; an invalid file refuses to boot. See Installing your license.- The server validates everything it can at startup — storage connectivity, partnership identifiers, license entitlements — and refuses to boot with a specific message rather than failing on the first partner message.
partnerships.toml
An array of [[partnership]] tables. Every field, with types and
defaults:
| Field | Type | Required | Meaning |
|---|---|---|---|
id | string | yes | unique configuration key; appears in logs, journal, metrics |
local_id | string | yes | our AS2 identifier for this relationship |
remote_id | string | yes | the partner’s AS2 identifier |
remote_url | string | no | partner endpoint; present = this partnership can send |
cert_aliases.local | string | yes | keystore alias of our key + certificate |
cert_aliases.remote | string | yes | keystore alias of the partner’s certificate |
AS2 identifiers: 1–128 printable ASCII characters; names containing spaces or quotes are automatically sent quoted per RFC 4130 §6.2 and matched unquoted on receive.
[partnership.security]
| Field | Type | Default | Meaning |
|---|---|---|---|
sign | digest name | unsigned | signing digest: md5, sha-1, sha-224, sha-256, sha-384, sha-512; prefix rsassa-pss- for PSS (e.g. rsassa-pss-sha-256) |
encrypt | cipher name | unencrypted | see Supported algorithms |
compress | "before-signing" | "after-signing" | none | ZLIB compression placement |
content_transfer_encoding | "binary" | "base64" | "quoted-printable" | "binary" | outer MIME encoding of outbound messages |
Declared sign/encrypt are also enforced as an inbound floor — see
Partnerships.
[partnership.mdn]
| Field | Type | Default | Meaning |
|---|---|---|---|
request | bool | false | request a receipt when sending |
signed_micalg | digest name | unsigned receipt | request a signed receipt with this digest |
async_url | string | sync | deliver the receipt asynchronously to this URL |
[partnership.http_auth]
| Field | Type | Meaning |
|---|---|---|
username / password | string | HTTP Basic credentials sent when POSTing to this partner (messages and async receipts) |
[partnership.interop]
All booleans; defaults in parentheses:
| Flag | Meaning |
|---|---|
include_cert_in_signature (true) | embed our certificate in CMS signatures (Peppol requires true) |
rfc3851_micalgs (false) | RFC 3851 micalg spellings (sha256) on the wire |
quote_header_values (false) | quote outbound header values |
block_error_mdn (false) | never send negative receipts to this partner |
bc_style_gcm (false) | send AES-GCM in the BouncyCastle-compatible encoding |
force_decrypt (false) | attempt decryption even when the content-type does not announce it |
disable_decrypt (false) | skip decryption (wins over force_decrypt) |
force_verify (false) | attempt verification even when unannounced |
disable_verify (false) | skip verification (wins over force_verify) |
disable_decompress (false) | skip decompression |
verify_use_cert_in_body_part (false) | verify against the certificate embedded in the message instead of the pinned one |
disable_* flags weaken the inbound security floor for that layer and
exist for migrations; prefer removing them once the partner is fixed.
The license file
A .lic file is a short piece of armored text: a human-readable payload
(base64 of TOML) plus an Ed25519 signature over it. Validation happens
locally against a public key embedded in the as2d binary — no network,
no telemetry.
AS2D-LICENSE-V1
KEY-ID: prod-1
<base64 payload>
SIGNATURE
<base64 signature>
The file survives careless handling: line-ending conversion and re-wrapping do not invalidate it. Any content change does.
Payload fields
| Field | Type | Meaning |
|---|---|---|
product | string | always "as2d" |
id | string | license serial (quote it in support requests) |
customer | string | licensee name |
issued | date | issue date |
type | "subscription" | "perpetual" | commercial model |
expires | date | subscription only — expiry (14-day grace follows) |
maintenance_until | date | perpetual only — latest build release date this license activates |
[features] (anything omitted is granted)
| Field | Default | Meaning |
|---|---|---|
send | true | outbound sending |
receive | true | inbound receiving |
async_mdn | true | asynchronous receipts (both requesting and honoring) |
algorithms | ["all"] | permitted encryption algorithms (wire names, see Algorithms) |
[limits] (anything omitted is unlimited)
| Field | Meaning |
|---|---|
messages_per_day | inbound messages per UTC day, per running instance; exhaustion answers 503 + Retry-After until midnight UTC |
partnerships_max | maximum configured partnerships |
Enforcement summary
- Signature/structure checked at startup and on each hourly re-read.
- Missing file → degraded boot (health answers, AS2 traffic 503s).
- Invalid file at startup → refuse to boot with the reason.
- Configuration exceeding entitlements → refuse to boot, naming the partnership and entitlement.
- Subscription expiry → 14 days of grace with warnings, then inbound 503 and outbound paused (queued work is kept, never dropped).
- Perpetual + newer build than
maintenance_untilcovers → that build refuses to start; your existing build keeps running forever. - Receipts for messages already sent are never blocked by license state.
To inspect a license file you have received, any text tool shows the
armor; your vendor can decode and re-verify it from the id if there is
ever a question.
Supported algorithms
Signing (RSA)
Config value for security.sign / mdn.signed_micalg:
| Name | Notes |
|---|---|
sha-256 | recommended default |
sha-384, sha-512 | |
sha-224 | |
sha-1 | legacy partners only |
md5 | legacy partners only |
rsassa-pss-sha-256 (also -sha-384, -sha-512, …) | RSASSA-PSS variants |
On the wire, digest names are emitted in RFC 5751 spelling (sha-256)
by default, RFC 3851 spelling (sha256) per partner via
interop.rfc3851_micalgs; inbound, all common spellings and aliases are
accepted and normalized.
Encryption (RSA key transport)
Config value for security.encrypt (also the wire names used in license
features.algorithms):
| Name | Notes |
|---|---|
aes128-cbc, aes192-cbc, aes256-cbc | recommended; universally interoperable |
3des | ubiquitous legacy default; fine for compatibility |
aes128-gcm, aes192-gcm, aes256-gcm | see GCM note below |
rc2 | legacy partners only (OpenSSL legacy provider, loaded automatically) |
cast5, idea | present but excluded from product support — see below |
The AES-GCM note
RFC 5084 specifies AuthEnvelopedData for GCM; the Java/BouncyCastle AS2
family instead ships GCM in plain EnvelopedData with the tag appended.
as2d receives both forms (tag always enforced). Sending defaults to
the conformant form; set interop.bc_style_gcm = true for Java-family
partners. Some non-Java counterparts support no GCM at all — AES-CBC is
the safe universal choice.
Why CAST5 and IDEA are unsupported
BouncyCastle and OpenSSL encode these ciphers’ CMS parameters incompatibly; exchanges fail in both directions with the entire Java AS2 family (measured, not theoretical). The ciphers remain in the binary for the rare non-Java counterpart, but they are outside the support and license scope, and as2d warns at startup when a partnership configures them.
Compression
ZLIB (RFC 3274 / RFC 5402), before-signing (the common form) or
after-signing. Inbound double compression is rejected
(decompression-failed), and decompression is capped by
limits.decompress_limit_mb.
Error dispositions
The disposition field of an MDN describes the outcome. as2d emits and understands the standard set; this table is what as2d sends and what each value means to your partner (and vice versa when reading partners’ receipts).
| Disposition | Class | When as2d sends it |
|---|---|---|
processed | success | message fully processed and stored |
processed/error: authentication-failed | error | AS2-From/AS2-To matched no partnership |
processed/error: insufficient-message-security | error | required signing/encryption layer missing (security floor) |
processed/error: decryption-failed | error | could not decrypt (wrong certificate, incompatible cipher, corrupt data) |
processed/error: integrity-check-failed | error | signature verification failed |
processed/error: decompression-failed | error | bad ZLIB data, or double compression |
processed/error: error-storing-transaction | error | storage backend failure (retryable by the partner) |
processed/error: unexpected-processing-error | error | anything else |
failed/Failure: unsupported format | failure | receipt requested with an unsupported signature protocol |
failed/Failure: unsupported MIC-algorithms | failure | receipt requested with only unsupported digests, marked required |
failed/Failure: unable to sign MDN | failure | signed receipt requested but our signing identity is unusable |
failed/Failure: unsupported receipt-delivery-option | failure | asynchronous receipt requested but not available on this deployment |
Notes for integrators:
- error vs failure:
processed/errormeans the message failed a processing step;failed/Failuremeans the receipt request itself could not be honored (RFC 4130 §7.5.3) — the message is not processed in that case either, and the partner should fix the request and resend. - Inbound receipts are parsed leniently: folded header lines, bare
modifiers (
processed/errorwithout text), spelling variations of digest names, and the nonstandard forms some products emit are all accepted and normalized before your journal sees them. - A partnership can set
interop.block_error_mdn = trueto suppress negative receipts to a specific partner (some products react badly to them); rejections are still journaled locally.