asx-rs 0.10.0

AS2 and AS4 B2B messaging library for Rust — signing, encryption, MDN, and ebMS3/AS4 profile support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
# Getting Started

## Installation

Add `asx-rs` to `Cargo.toml`. Because AS2 and AS4 are feature-gated, you must select at least one protocol:

```toml
[dependencies]
# AS2 only
asx-rs = { version = "0.10", features = ["as2", "async-ocsp"] }

# AS4 only
asx-rs = { version = "0.10", features = ["as4", "async-ocsp"] }

# Both protocols
asx-rs = { version = "0.10", features = ["as2", "as4", "async-ocsp"] }

# Both protocols with payload compression (RFC 5402)
asx-rs = { version = "0.10", features = ["as2", "as4", "compression", "async-ocsp"] }

# HTTP client (outbound) + server (inbound) with both protocols
asx-rs = { version = "0.10", features = ["as2", "as4", "client", "server", "async-ocsp"] }

# Relaxed interop for explicitly scoped partner exceptions
asx-rs = { version = "0.10", features = ["as2", "as4", "interop-relaxed", "async-ocsp"] }
```

> **Note:** The default feature set is `["interop-strict", "async-ocsp"]`. Adding `asx-rs` without explicit features gives you only the shared infrastructure — no AS2 or AS4 protocol functions are compiled.

## Feature Flag Reference

| Feature | Enables | Default |
|---|---|---|
| `as2` | `as2::send_sync` / `as2::receive_sync`, async wrappers, MDN generation/parsing, MIC computation | No |
| `as4` | `as4::send_sync` / `as4::receive_push_with_dedup_sync`, pull APIs, P-Mode registry, Test Service, SBDH | No |
| `compression` | Zlib/GZIP payload compression via `flate2` | **Yes** |
| `async-ocsp` | Async OCSP responder fetching via reqwest | **Yes** |
| `interop-strict` | Strict interop mode as the default profile | **Yes** |
| `interop-relaxed` | Relaxed-mode controls for explicitly scoped partner exception policies | No |
| `client` | Async HTTP egress (`As2HttpTransport`, `As4HttpTransport` via reqwest) | No |
| `server` | Axum HTTP server routers (`as2_router`, `as4_router`) | No |
| `trace` | `tracing` instrumentation on send/receive paths | **Yes** |
| `prometheus` | Built-in Prometheus/OpenMetrics `MetricsSink` adapter | No |
| `opentelemetry` | OpenTelemetry metrics `MetricsSink` adapter | No |
| `testing` | `InsecureBypassAs4Verifier`, `MockAs4Endpoint` (with `builder().with_decryption_key_pem()`), `EventBus::new_for_testing()`, `As4HttpTransport::new_for_localhost_testing()`, keypair generators, interop matrix executor | No |

## Tokio Runtime

`asx-rs` requires the Tokio async runtime. Add to `Cargo.toml`:

```toml
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```

## Regulated Startup Gate (Strict Production)

In regulated deployments, validate runtime wiring before accepting traffic:

```rust
use std::sync::Arc;

use asx_rs::presets::{
    DeploymentTopology,
    StrictRuntimeBootstrapToken,
    issue_strict_runtime_bootstrap_token_with_as4_topology,
    strict_production_event_bus,
};
use asx_rs::storage::{DedupStorage, ReconciliationStorage};
use asx_rs::as4::{As4ConversationOrderGate, As4PullStore};

fn bootstrap_strict_runtime(
    reconciliation: Arc<dyn ReconciliationStorage>,
    dedup: Arc<dyn DedupStorage>,
    audit_sink: Arc<dyn asx_rs::observability::audit_sink::DurableAuditSink>,
    pull_store: &As4PullStore,
    conversation_gate: &As4ConversationOrderGate,
) -> asx_rs::Result<asx_rs::observability::EventBus> {
    let bus = strict_production_event_bus(1024, audit_sink)?;

    let _token: StrictRuntimeBootstrapToken = issue_strict_runtime_bootstrap_token_with_as4_topology(
        "startup",
        &bus,
        reconciliation.as_ref(),
        dedup.as_ref(),
        DeploymentTopology::Clustered,
        Some(pull_store),
        Some(conversation_gate),
    )?;

    Ok(bus)
}
```

This fails closed when any strict-production invariant is missing (non-transactional event mode, missing durable audit sink, non-durable or non-cluster-safe reliability backends, or clustered AS4 startup with process-local pull/order coordination). In non-testing builds, strict interop entry points also fail closed unless startup validation is bound to the session with `asx_rs::presets::session_with_strict_runtime_bootstrap_token`.

Migration note: bind strict-runtime once per session with `session_with_strict_runtime_bootstrap_token(...)`, then call standard AS2/AS4 ingress helper methods.

## Session Configuration (`SessionContextBuilder`)

`SessionContextBuilder` is the primary way to configure sessions. All convenience
setters auto-derive `key_id` from `partner_id` — no manual `CertHandle` construction
needed for common cases:

```rust
use asx_rs::core::SessionContextBuilder;

// Minimal trust-anchor-only session (receive path)
let session = SessionContextBuilder::new("sess-recv", "partner-gln")
    .with_trust_anchor_pem(partner_root_ca_pem)
    .with_fingerprint_sha256("aabb1122...")   // optional: pin exact partner cert
    .build()?;

// Send session: signing material + trust in one chain
let session = SessionContextBuilder::new("sess-send", "partner-gln")
    .with_signing_material(our_cert_pem, our_key_pem) // sets both cert + key together
    .with_trust_anchor_pem(partner_root_ca_pem)
    .build()?;

// Combine with per-request credential override (partial override merges with session)
// Only recipient_cert_pem is overridden; signing material falls back to session
let credentials = Some(As4SendCredentials {
    recipient_cert_pem: Some(partner_enc_cert),
    ..Default::default()  // signing_{cert,key}_pem = None → falls back to session
});
```

`CertHandle` is now a **pure data struct** (all fields `pub`) — struct update syntax works
from external crates with no restrictions:

```rust
let ch = asx_rs::core::CertHandle {
    trust_anchor_pems: vec![root_ca_pem],
    fingerprint_sha256: "aa...".into(),
    ..asx_rs::core::CertHandle::new("my-key")  // ← no E0451 compile error in 0.8
};
// Assign signing key without depending on `zeroize` directly:
ch.set_signing_key_pem(my_key_pem);
```

## Quick Start: AS2 Send

```rust
use asx_rs::as2::{send_sync, As2SendCredentials, As2SendPolicy, As2SendRequest};
use asx_rs::core::SessionContextBuilder;
use asx_rs::observability::EventBus;

fn main() -> asx_rs::Result<()> {
    // Fluent session builder — key_id auto-derived, signing material validated eagerly
    let session = SessionContextBuilder::new("sess-as2-1", "partner-acme")
        .with_signing_material(
            std::fs::read_to_string("sender-cert.pem")?,
            std::fs::read_to_string("sender-key.pem")?,
        )
        .with_trust_anchor_pem(std::fs::read_to_string("partner-root-ca.pem")?)
        .build()?;
    let bus = EventBus::new(64)?;

    let policy = As2SendPolicy { sign: true, encrypt: true, ..Default::default() };
    let creds = As2SendCredentials {
        // signing material is optional here — falls back to session when None
        recipient_cert_pem: Some(std::fs::read("partner-cert.pem")?),
        ..Default::default()
    };

    let output = send_sync(
        &session,
        &bus,
        As2SendRequest {
            message_id: "msg-001@example.com".to_string(),
            payload: b"ISA*...".to_vec(),
            policy,
            credentials: creds,
        },
    )?;
    // output.http_headers          — ready-to-send AS2 HTTP headers
    // output.mime.body             — MIME body bytes to POST
    // output.mime.content_type     — HTTP Content-Type header value
    // output.as_received_content_mic() — MIC string for MDN cross-check
    Ok(())
}
```

## Quick Start: AS4 Send

```rust
use asx_rs::as4::{send_sync, As4SendPolicyBuilder, As4SendRequest};
use asx_rs::core::SessionContextBuilder;
use asx_rs::observability::EventBus;

fn main() -> asx_rs::Result<()> {
    // Session carries signing material; no per-request credentials needed
    let session = SessionContextBuilder::new("sess-as4-1", "partner-b")
        .with_signing_material(
            std::fs::read_to_string("sender-cert.pem")?,
            std::fs::read_to_string("sender-key.pem")?,
        )
        .with_trust_anchor_pem(std::fs::read_to_string("partner-root-ca.pem")?)
        .build()?;
    let bus = EventBus::new(64)?;

    let (policy, creds) = As4SendPolicyBuilder::new()
        .signing_cert_pem(std::fs::read("sender-cert.pem")?)
        .signing_key_pem(std::fs::read("sender-key.pem")?)
        .build()?;

    let output = send_sync(
        &session,
        &bus,
        As4SendRequest {
            message_id: "uuid-001@example.com".to_string(),
            payload: b"<Order>...</Order>".to_vec(),
            policy,
            credentials: Some(creds),
            payload_filename: None,
        },
    )?;
    // output.soap_envelope.body — multipart/related bytes
    // output.http_content_type  — HTTP Content-Type for transport
    Ok(())
}
```

## Quick Start: AS2 Receive (Framework-Agnostic)

```rust
use asx_rs::as2::{receive_sync, CmsSmimeTrustVerifier};
use asx_rs::transport::ingress::{As2HttpIngress, as2_ingress_from_http};
use asx_rs::http::HttpRequest;
use asx_rs::core::SessionContext;

fn main() -> asx_rs::Result<()> {
    // Build a framework-agnostic HttpRequest (e.g., from your web framework)
    let http_req = HttpRequest { /* ... */ };
    let ingress = as2_ingress_from_http(http_req)?; // validates required headers

    let session = SessionContext::new("sess-as2-2", "partner-acme", "strict")?;
    let verifier = CmsSmimeTrustVerifier::default();
    let trusted = receive_sync(&session, ingress.body.to_vec(), &verifier)?;

    // trusted holds the cryptographically verified/decrypted domain payload.
    println!("payload bytes: {}", trusted.as_ref().len());
    Ok(())
}
```

## Quick Start: Axum HTTP Server (AS2)

Enable the `server` and `as2` features, then:

```rust
use asx_rs::transport::server::{as2_router, As2AxumHandler, HandlerOutcome};
use asx_rs::transport::ingress::As2HttpIngress;
use std::sync::Arc;

struct MyAs2Handler;

#[async_trait::async_trait]
impl As2AxumHandler for MyAs2Handler {
    async fn handle(&self, ingress: As2HttpIngress) -> HandlerOutcome {
        // process ingress.body, ingress.as2_from, ingress.as2_to, ingress.message_id, etc.
        HandlerOutcome::ok()
    }
}

#[tokio::main]
async fn main() {
    let handler = Arc::new(MyAs2Handler);
    let app = as2_router(handler, "/as2/receive");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
```

## End-to-End AS2: Send, Receive MDN, and Verify

This example shows the complete AS2 cycle: build a message, send it over HTTP,
receive the synchronous MDN from the partner, and verify the MIC.

```rust
use asx_rs::as2::{
    send_sync, receive_with_mdn_with_reliability,
    As2SendCredentials, As2SendPolicy, As2SendRequest,
    As2ReceiveMdnRequest, As2MdnMode, As2ReceivePolicy, CmsSmimeTrustVerifier,
};
use asx_rs::core::SessionContext;
use asx_rs::observability::{BackpressurePolicy, EventBus, EventEmissionMode};
use asx_rs::storage::{InMemoryDedupStorage, InMemoryReconciliationStorage};
use std::sync::Arc;

fn end_to_end_as2() -> asx_rs::Result<()> {
    // 1. Build a reusable session (one per trading-partner relationship).
    let session = SessionContext::new("sess-acme-prod", "partner-acme", "strict")?;
    let bus = EventBus::new_with_config_and_mode(
        128,
        None,
        BackpressurePolicy::default(),
        EventEmissionMode::BestEffort,
    )?;

    // 2. Prepare credentials (load once; reuse across messages).
    let creds = As2SendCredentials {
        signing_cert_pem: Some(std::fs::read("sender-cert.pem")?),
        signing_key_pem:  Some(std::fs::read("sender-key.pem")?),
        recipient_cert_pem: Some(std::fs::read("partner-cert.pem")?),
        ..Default::default()
    };

    // 3. Build and sign/encrypt the AS2 message.
    let payload: Arc<[u8]> = b"ISA*00*...".to_vec().into();
    let msg_id = "msg-001@acme.example.com";
    let output = send_sync(
        &session,
        &bus,
        As2SendRequest {
            message_id: msg_id.to_string(),
            payload: payload.to_vec(),
            policy: As2SendPolicy { sign: true, encrypt: true, ..Default::default() },
            credentials: creds,
        },
    )?;
    // POST output.mime.body to the partner AS2 URL using output.mime.content_type
    // as the HTTP Content-Type header, plus the headers in output.http_headers.

    // 4. Receive the synchronous MDN bytes from the HTTP response body.
    //    In production use `As2HttpTransport` (feature = "client") or your HTTP client.
    let mdn_response_bytes: Vec<u8> = vec![/* raw MDN HTTP response body */];

    // 5. Verify the MDN and check the MIC matches.
    //    Use TtlDedupStorage / durable backends instead of in-memory in production.
    let dedup = InMemoryDedupStorage::default();
    let reconciliation = InMemoryReconciliationStorage::new(1024);
    let verifier = CmsSmimeTrustVerifier::default();

    let mdn_result = receive_with_mdn_with_reliability(
        &session,
        &bus,
        As2ReceiveMdnRequest {
            payload: Arc::clone(&payload),
            mdn_payload: mdn_response_bytes.into(),
            mdn_mode: As2MdnMode::Synchronous,
            // output.as_received_content_mic() returns "base64==, sha-256" for cross-check
            expected_mic: Some(output.as_received_content_mic()),
            policy: As2ReceivePolicy::default(),
            original_message_id: Some(msg_id.to_string()),
        },
        &reconciliation,
        &dedup,
        &verifier,
    )?;

    // mdn_result.outcome — SuccessConfirmed, Indeterminate, or AcceptedPendingVerification
    println!("AS2 message {} MDN outcome: {:?}", msg_id, mdn_result.outcome);
    Ok(())
}
```

**Key points:**
- `output.as_received_content_mic()` returns the RFC 4130 MIC string — pass it to `expected_mic` so the MDN cross-check validates both digest value and algorithm.
- `receive_with_mdn_with_reliability` verifies the MDN signature, checks the MIC, emits audit events, and queues a `ReconciliationRequest` for indeterminate outcomes.
- The `session` is long-lived; recreating it per message wastes cert-validation work.
- Use `TtlDedupStorage` (or a distributed backend) rather than `InMemoryDedupStorage` in production.

## Quick Start: AS4 Receive (Push)

```rust
use asx_rs::as4::As4PushPolicy;
use asx_rs::transport::ingress::{As4HttpIngress, As4IngressReceivePushSyncRequest};
use asx_rs::core::SessionContext;
use asx_rs::observability::EventBus;
use asx_rs::storage::DedupStorage;
use std::sync::Arc;

fn receive_as4_push(
    ingress: As4HttpIngress,           // built from your HTTP framework's request
    dedup: Arc<dyn DedupStorage>,      // persistent dedup store (prevents replay)
    session: &SessionContext,
    bus: &EventBus,
) -> asx_rs::Result<()> {
    let received = ingress.receive_push_with_dedup_sync(As4IngressReceivePushSyncRequest {
        session,
        event_bus: bus,
        policy: As4PushPolicy::default(),
        dedup_backend: dedup.as_ref(),
        receipt_payload: None,
    })?;

    // received.payload — DomainReady<Arc<[u8]>>: verified, decrypted domain payload
    // received.user_message.message_id — ebMS3 MessageId (dedup-checked)
    // received.user_message.from_party_id() — primary sender party ID
    println!(
        "Received AS4 push: {} ({} bytes)",
        received.user_message.message_id,
        received.payload.as_ref().len(),
    );
    Ok(())
}
```

## Next Steps

- [Architecture]architecture.md — module map and design decisions
- [AS2 Protocol Reference]as2.md — send, receive, MDN, MIC, compression
- [AS4 Protocol Reference]as4.md — send, receive push/pull, WS-Security, P-Mode, SBDH
- [HTTP Transport]transport.md — client and server integration
- [Security Model]security.md — trust model, certificate handling, crypto algorithms
- [Reliability]reliability.md — dedup, reconciliation, retry
- [Persistence How-To]persistence-howto.md — production persistence adapters for dedup/reconciliation/audit
- [Observability]observability.md — EventBus, audit events, backpressure, audit sinks
- [Interoperability]interop.md — profile stack, strict/relaxed modes
- [Testing]testing.md — test harness, fuzz testing, interop matrix
- [Release Process]release.md — quality gates, CI, checklist