feat: phase c - AS2 transport #10

Merged
KofTwentyTwo merged 1 commits from feature/phase-c-as2 into develop 2026-10-14 13:27:25 +00:00
44 changed files with 6259 additions and 61 deletions

No files matched your search

+1 -1
View File
@@ -53,7 +53,7 @@ The package layout per phase is in `docs/DESIGN.md`.
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
Middleware Javalin is optional. Planned: StAEDI and as2-lib (both Apache-2.0).
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
## Release
+7
View File
@@ -7,6 +7,13 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
### Added
- Phase d warehouse and logistics baseline guides for 940, 945, 214 and 204 in 004010 and 005010 (syntax notes, situational control-total and stop rules, AT7 pairing), inbound/outbound sample maps (940 in, 945 out/in, 214 in, 204 in/out), DATEPART/TIMEPART outbound transforms and `+`-joined inbound paths for date/time pairs, and Harbor Logistics and Ridgeline Freight demo traffic.
||||||| parent of bfb63ce (feat: phase c - AS2 transport)
- Phase c AS2: `As2Transport` send and receive (RFC 4130) with S/MIME signing (SHA-256/384/512; SHA1 flagged), encryption (AES-128/256 CBC or GCM; 3DES flagged), optional compression, synchronous and asynchronous signed MDNs with MIC verification, Test Connection, optional TLS pinning and VAN transports with no partner. Built on as2-lib 5.1.8 and BouncyCastle, entirely in memory (no keystore or temporary files).
- `POST /qqq/hooks/edi/as2/{transport}` and `/{transport}/mdn` route provider: identity, decryption (ACTIVE or NEXT keys), signature and MIC checks, Message-ID dedupe returning the original MDN, error MDNs with no inbox row, and a queued, retried asynchronous MDN sender.
- Read-only `edi_as2_mdn` table with "AS2 MDNs" child lists on outbound and inbound messages; raw MDNs in the encrypted payload store; `edi_outbound_message.transport_mic`; `edi_transport.weak_crypto`; `EdiQBitConfig.publicBaseUrl`.
- Certificates: parsed on save (private keys refused), keys from environment PEM PKCS#8 or base64 PKCS#12, ACTIVE/NEXT rollover, record actions Promote Certificate and Export Public Certificate, expiry alerts for ACTIVE and NEXT, and the `edi.mdn.overdue` alert.
- Demo: Pacific Wholesale over AS2 against an in-process BouncyCastle peer with in-memory certificates; AS2 acceptance tests in both directions against our own route and an independent peer.
- Phase b retail baseline guides for 855/856/810/846/860 in 004010 and 005010; inbound/outbound sample maps and nested outbound associations with SEQUENCE/HL transforms.
- Transactional RECORD_CHANGE and manual enqueue, host-wired ESB process, batch schedules, validated encrypted envelopes, send workers, retries and dead-letter alerts.
- Instance transport registry, MINA SFTP streaming push/poll with pinned host keys, gateway OUTBOX stand-in/download delivery, and authenticated HTTPS ingress/egress using core Standard Webhooks.
+1 -1
View File
@@ -53,7 +53,7 @@ The package layout per phase is in `docs/DESIGN.md`.
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
Middleware Javalin is optional. Planned: StAEDI and as2-lib (both Apache-2.0).
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
## Release
+60 -4
View File
@@ -5,10 +5,11 @@ and application, and TA1/997/999 acknowledgements. See [the design](docs/DESIGN.
## Status
Phases a1, a2 and b implement inbound/outbound retail EDI. Original baseline guides cover
Phases a1, a2, b and c implement inbound/outbound retail EDI over gateway, SFTP, HTTPS and AS2. Original baseline guides cover
850, 855, 856, 810, 846 and 860 in 004010/005010, plus 997/999. Source records and associations
produce validated, encrypted outbound interchanges, with control-number tracking, retries,
acknowledgement monitoring and operator resend/cancel actions. AS2 and email sending remain disabled.
acknowledgement monitoring and operator resend/cancel actions. AS2 (signed, encrypted, compressed, synchronous and
asynchronous MDN, certificate rollover) is built in; email sending remains disabled until the qbit-email API ships.
A runnable synthetic [demo](docs/DEMO.md) includes Next UI and needs no external services.
## Requirements and installation
@@ -112,6 +113,8 @@ Full table permissions include `edi.partner.read|insert|edit|delete`, `edi.map.*
| Resend / Resend As New | `edi.resend` |
| Cancel | `edi.cancel` |
| Test Connection | `edi.transport.test` |
| Promote Certificate | `edi.certificate.promote` |
| Export Public Certificate | `edi.certificate.read` |
| Scheduled workers / ESB enqueue | `edi.admin` |
Partner locks use `edi_partner` with `edi.admin` all-access. Home queries explicitly enforce
@@ -131,6 +134,7 @@ the viewing user's permissions. Processes are table/record actions, never naviga
| `payloadStore` | `DATABASE` or `OBJECT_STORAGE` |
| `payloadBucketAlias` | `edi` |
| `secretsResolver` | Environment-variable lookup |
| `publicBaseUrl` | Null; the deployment base URL (for example `https://edi.example.com`), required for ASYNC AS2 receipts |
| `clock` | UTC system clock |
| `esbDestinationName` | Null; optional core ESB destination |
@@ -146,7 +150,7 @@ alias and select `OBJECT_STORAGE`. EDI uses only its public API and system acces
Optional `qqq-esb` publishes setup/activity events after commit. Document events contain only ID
and status because RC.5's standard factory otherwise copies PHI fields. Alert names are
`edi.inbox.dead-letter`, `edi.ack.overdue`, `edi.ack.rejected`, `edi.send.failed`,
`edi.certificate.expiring` and `edi.certificate.expired` (RC.5 requires dotted names).
`edi.certificate.expiring`, `edi.certificate.expired` and `edi.mdn.overdue` (RC.5 requires dotted names).
RC.5 lacks the planned core `DataClassification` API. `EdiClassifications` marks PHI/PII/confidential
fields and PHI fields deny AI exposure. Hosts must supply secured-data protection, database
@@ -180,7 +184,8 @@ TRANSPORT exception and raise `edi.send.failed`.
| GATEWAY | `gatewayEndpointName`; marked `FileGatewayOutbox` stand-in with a provider registration hook. Put is SENT; `EdiFileGatewayConsumer.onDownloaded` is DELIVERED. |
| SFTP | MINA client from core's filesystem module; required `sftpHostKeyFingerprint` in MINA SHA256 format, username and password/private-key env name. Streams upload to `.tmp`, then rename. `sftpPollCron` schedules remote pickup; `sftpFilePattern` is a Java regex (default `.*\.x12`). DELETE/MOVE occurs only after durable inbox commit. |
| HTTPS | POST with NONE/BASIC/BEARER/HMAC; TLS required except loopback. BASIC's secret is `user:password`; BEARER's is the token; HMAC uses core Standard Webhooks `whsec_` format. `httpsHeadersJson` holds nonsecret headers only. Redirects are refused; 2xx is DELIVERED, 429/5xx retry. |
| AS2 / EMAIL | Settings validation only. Sending returns a permanent unavailable result until phase c / the email API. |
| AS2 | See [AS2](#as2). |
| EMAIL | Settings validation only. Sending returns a permanent unavailable result until the qbit-email API ships. |
Optional Javalin registers `POST /qqq/hooks/edi/https/{transport}`. `httpsInboundAuth` must be
BASIC, BEARER or HMAC with `httpsSecretEnvVar`. It authenticates exact bytes, caps intake at
@@ -200,6 +205,57 @@ without re-running its map and links `originalInterchangeId`. Cancel accepts QUE
Existing a2 databases need `edi_interchange.original_interchange_id INTEGER` and a unique index
on `edi_outbound_request(agreement_document_id, event_id)` in addition to their prior schema.
## AS2
`As2Transport` (type `AS2`) sends and receives AS2 (RFC 4130) with S/MIME signing and encryption
(SHA-256/384/512, AES-128/256 CBC or GCM; SHA1 and 3DES only when set explicitly and flagged by
the read-only `weakCrypto` field), optional zlib compression, and synchronous or asynchronous
signed MDNs. Everything is in memory: no keystore, key or temporary file is ever written.
**Transport settings** (`edi_transport`, type AS2): `as2LocalId`, `as2PartnerId`, `as2Url` (HTTPS;
plain HTTP only on loopback), `as2Subject`, `signAlgorithm` (blank = unsigned), `encryptAlgorithm`
(blank = unencrypted), `compress`, `mdnMode` (`SYNC` default, `ASYNC`, `NONE`), `mdnSigned`,
`localCertificateId` (ours), `partnerCertificateId`, optional `partnerTlsCertificateId` (pins the
partner TLS certificate), and optional `httpAuthUserEnvVar` / `httpAuthPasswordEnvVar` for HTTP
Basic. For inbound, a set `signAlgorithm` / `encryptAlgorithm` means signed / encrypted messages are
required (otherwise `insufficient-message-security`). A VAN is an AS2 transport with `isVan` true
and no `partnerId`; its partner certificate belongs to the VAN operator's partner record, and
routing to trading partners stays by ISA identity.
**Certificates** (`edi_certificate`): paste the public PEM only; subject, issuer, serial,
SHA-256 fingerprint and validity are parsed on save, and a pasted private key is refused. Our private
key is never stored: `privateKeyEnvVar` names an environment variable holding a PEM PKCS#8 key or a
base64 PKCS#12 store (with `privateKeyPasswordEnvVar` for its password). A missing or mismatched
variable fails plainly, naming only the variable. Status `ACTIVE` signs and encrypts outbound;
inbound accepts `ACTIVE` and `NEXT` (ours for decryption, the partner's for verification), so a
rollover is: add the new certificate as `NEXT`, tell the partner, then run **Promote Certificate**
(`NEXT` becomes `ACTIVE`, the previous `ACTIVE` of the same partner and usage becomes `RETIRED`).
**Export Public Certificate** downloads the PEM to send to a partner. The monitor alerts at 30, 14
and 7 days before expiry and at expiry for `ACTIVE` and `NEXT` certificates.
**Hook URLs** (outside `/qqq/v1`, authenticated per transport by AS2 identity, signature and
certificate; give your partner the first, and the second is sent in `Receipt-Delivery-Option`):
| URL | Purpose |
| --- | --- |
| `POST {base}/qqq/hooks/edi/as2/{transport}` | Receive messages; answers with the MDN, or 200 and a queued asynchronous MDN |
| `POST {base}/qqq/hooks/edi/as2/{transport}/mdn` | Receive asynchronous MDNs for messages we sent |
`{transport}` is the transport `name`. Set `EdiQBitConfig.withPublicBaseUrl(...)` so `ASYNC`
transports can announce the second URL. Bodies are limited by the largest `maxInterchangeBytes` of
the agreements on the transport (50 MB default). A retransmitted Message-ID is answered with the
original MDN and is not reprocessed; a bad signature, decryption or MIC returns an MDN with
`processed/error: authentication-failed`, `decryption-failed` or `integrity-check-failed` and creates
no inbox row; a storage failure returns `unexpected-processing-error` so the partner retries. Every MDN
sent or received is kept in the read-only `edi_as2_mdn` table (AS2 MDNs child lists on the
outbound message and the inbound message) with the raw MDN in the encrypted payload store.
`ASYNC` messages stay SENT until the MDN arrives; the `edi_as2_mdn_sender` step delivers the MDNs we
owe, retrying with the send backoff, and `edi.mdn.overdue` warns when an `ASYNC` MDN is later than
the agreement document `ackDueMinutes` (60 when unset).
Existing databases need the new `edi_as2_mdn` table, `edi_outbound_message.transport_mic` and
`edi_transport.weak_crypto` (see `docs/ddl/edi-postgres.sql`).
## Build and verification
```bash
+14 -1
View File
@@ -19,7 +19,20 @@ invoices and inventory. Forty synthetic inbound documents go through intake and
850s, 860s, validation errors, an unknown sender and a byte duplicate. Northwind's 855/856/810/846
pass the outbound worker and an in-memory gateway; incoming 997s reconcile some sent groups.
An overdue group, an exhausted send and several open exceptions provide operational examples.
Two public self-signed certificates are generated in memory; private keys are never persisted.
Public self-signed certificates are generated in memory (two for expiry alerts, three for AS2); private keys are never persisted.
Pacific Wholesale (code PACIFIC) is the AS2 partner. An in-process BouncyCastle peer listens on a
random loopback port and the seed generates synthetic RSA certificates in memory (our ACTIVE and NEXT
certificates and the partner's; the private keys live only in the demo secrets map and are never
written to disk). The seed sends three 850s to the application's own AS2 hook (signed, encrypted;
two with base64 transfer encoding), which answers with signed MDNs (`edi_as2_mdn` rows, shown as
"AS2 MDNs" on the inbound message); the 997s go back over AS2 as well. An 855 built from a source record is sent
over AS2 to the peer, which verifies the signature and MIC and returns a signed synchronous MDN
(the outbound message becomes DELIVERED with its `transport_mic`). One more message goes to the
asynchronous transport `pacific-as2-async`: it stays SENT until the peer posts the MDN to
`/qqq/hooks/edi/as2/pacific-as2-async/mdn`. The running demo serves
`POST /qqq/hooks/edi/as2/pacific-as2` (and `.../mdn`) on the same port; Test Connection,
Export Public Certificate and Promote Certificate (on `demo-as2-next`) work on the AS2 records.
Phase d adds warehouse and logistics traffic, seeded last so the retail scenarios above are
unchanged. Harbor Logistics (the 3PL) gets a gateway OUTBOX transport (the partner pulls over SFTP)
+77 -24
View File
@@ -349,30 +349,61 @@ For partners and VANs that host their own SFTP, EDI connects out with core's SFT
not depend on it (G1) but uses the same core client, so behaviour and host-key handling match.
### 5.5 AS2 (phase c)
Built on as2-lib (Apache-2.0):
- **Send:** `AS2Client` with partnership settings built from `edi_transport` and `edi_certificate`
(a custom partnership factory and certificate factory backed by our tables, keys from env vars, no
keystore file). Sign, then encrypt, optional compression; the `Message-ID` is recorded. Sync MDN:
verify the signature and the MIC against the sent content; a mismatch or a failed disposition
fails the send. Async MDN: `Receipt-Delivery-Option` is
`https://<host>/qqq/hooks/edi/as2/{transport}/mdn`; the message stays SENT until the MDN arrives,
then becomes DELIVERED or FAILED. An MDN not received within `ackDueMinutes` raises
`edi_mdn_overdue`.
- **Receive:** `POST /qqq/hooks/edi/as2/{transport}` (a route provider outside `/qqq/v1`). The
handler reads the body up to the agreement's `maxInterchangeBytes` (in memory up to 8 MB, then
streamed to object storage, never to a temp file), uses as2-lib's receive helpers to decrypt,
verify the signature against the partner's certificate, decompress and compute the MIC, then calls
`InboundSink.receive` and returns the signed sync MDN, or sends the async MDN after commit. A
message that cannot be stored gets a failure MDN (`processed/error:
unexpected-processing-error`) so the partner retries.
- The AS2 `Message-ID` is the inbox dedupe key: a retransmitted message gets the original MDN again
and is not reprocessed.
- MDNs, sent and received, are stored in **`edi_as2_mdn`** [AS2 MDN] (child of the outbound message
or the inbox row): `direction`, `messageId`, `originalMessageId`, `disposition`, `micAlgorithm`,
`micValue`, `signed`, `signatureValid`, `payloadRef` (the raw MDN in object storage),
`receivedAt`.
- TLS: outbound uses the JVM trust store plus optional pinning (`partnerTlsCertificateId`). Inbound
TLS terminates at the host's ingress.
Implemented by `As2Transport` (send, Test Connection) and `As2RouteProvider` (receive). All of it is
in memory; no keystore, key or temporary file is written, and as2-lib's file-backed pieces are not used.
- **Libraries.** as2-lib 5.1.8 supplies the algorithm tables (`ECryptoAlgorithmSign/Crypt`), the
`MIC` and `DispositionOptions` types and its BouncyCastle S/MIME signer. as2-lib's decrypt and
verify stage content through `AS2ResourceHelper` temporary files and its receive side needs
servlet classes, so decryption, signature verification, compression, encryption and the MIC
(RFC 4130, RFC 5751, RFC 5402) use BouncyCastle (`bcpkix`, `bcjmail`) directly in `As2Crypto`,
and MDNs are built and parsed in `As2Mdn`. The MIC is the digest of the exact bytes of the signed
MIME entity (headers included), or of the transmitted entity when unsigned, so any partner
transfer encoding (binary or base64) agrees.
- **Send:** the message is compressed (optional), signed, then encrypted; `Message-ID` is stable per
outbound message (`edi-<id>-<digest>@<as2LocalId>`) and recorded as `transportMessageId`, and the MIC
we computed is kept in `edi_outbound_message.transportMic`. Configuration problems (a missing key
variable, an expired certificate) are RETRY results that name only the problem. Sync MDN: verify the
signature (required when `mdnSigned`), compare Received-Content-MIC, and require disposition
`processed`; a mismatch or failed disposition is FAILED with the (sanitized) disposition text.
`NONE` leaves the message SENT. Async MDN: `Receipt-Delivery-Option` is
`{publicBaseUrl}/qqq/hooks/edi/as2/{transport}/mdn`; the message stays SENT until the MDN arrives,
then becomes DELIVERED or FAILED; an MDN not received within the agreement document `ackDueMinutes`
(60 when unset) raises the warning `edi.mdn.overdue`, resolved by the MDN.
- **Receive:** `POST /qqq/hooks/edi/as2/{transport}` (outside `/qqq/v1`). The handler checks
`AS2-From`/`AS2-To` against the transport (403 otherwise), reads the body up to the largest
`maxInterchangeBytes` of the agreements on the transport (50 MB default, in memory), decrypts with any
of our ACTIVE or NEXT keys, verifies the signature against the partner's ACTIVE and NEXT certificates
(signed is required when `signAlgorithm` is set, encrypted when `encryptAlgorithm` is set), unwraps
compression, computes the MIC, calls `InboundSink.receive` (`transportType` AS2, `externalId` =
Message-ID, scoped by transport id if another partner already used the same id), and answers with the
MDN: synchronous in the response (signed with our ACTIVE key when requested or `mdnSigned`), or, when
`Receipt-Delivery-Option` is present, 200 at once and a queued `edi_as2_mdn` row that
`As2MdnSender` (scheduled `edi_as2_mdn_sender`, every 15 seconds) posts with the send backoff and
`maxAttempts`, failing with `edi.send.failed`. An asynchronous receipt address must be HTTPS
(loopback excepted) and, unless signatures are required, on the partner's own host; failures before
the sender is authenticated are always answered synchronously.
- A retransmitted Message-ID returns the original MDN again, byte for byte, and is not reprocessed
(an asynchronous one is queued again). Failures return MDNs with `processed/error:
authentication-failed` (bad or unverifiable signature), `integrity-check-failed` (digest mismatch),
`decryption-failed`, `insufficient-message-security` or `unexpected-processing-error` (storage, or a
key we cannot load, so the partner retries); no inbox row exists for a rejected message.
- **Certificates:** `CertificateService` resolves our key from the `privateKeyEnvVar` variable (PEM
PKCS#8 or base64 PKCS#12 with `privateKeyPasswordEnvVar`), proves it matches the certificate, and
never caches or logs it. Outbound signs and encrypts with the ACTIVE certificate (the referenced one
when ACTIVE, otherwise the ACTIVE one of the same partner and usage), inbound accepts ACTIVE and NEXT.
"Promote Certificate" makes NEXT ACTIVE and retires the previous ACTIVE one of the same partner and
usage. A VAN transport (`isVan`, no `partnerId`) has its partner certificate on the VAN operator's
partner record, or with no partner for a shared certificate (no rollover candidates).
- MDNs, sent and received, are stored in **`edi_as2_mdn`** [AS2 MDN] (read-only; child list "AS2 MDNs"
of the outbound message and the inbox row; record label `<direction> MDN <originalMessageId>`):
`direction` (INBOUND = received, OUTBOUND = we sent it), `messageId`, `originalMessageId`,
`disposition`, `micAlgorithm`, `micValue`, `signed`, `signatureValid`, `payloadRef` (the raw MDN,
encrypted), `receivedAt`, `transportId`, `partnerId`, `outboundMessageId`, `inboxId`, and the
queue columns `deliveryUrl`, `deliveryStatus` (NONE, PENDING, SENT, FAILED), `deliveryAttempts`,
`nextAttemptAt`, `deliveredAt`.
- TLS: outbound uses the JVM trust store, or only the pinned certificate when `partnerTlsCertificateId`
is set. Inbound TLS terminates at the host's ingress. SHA1 and 3DES are accepted only when set
explicitly and set the read-only `weakCrypto` flag on the transport.
### 5.6 HTTPS and email
- **HTTPS out:** POST the interchange (`application/edi-x12` or `application/EDIFACT`) with the
@@ -924,3 +955,25 @@ RC.5 adaptations implemented in this worktree:
- `InboundApplier` assumed every target table has associations; a flat table (the 214 status sample) hit a null pointer. It is now null-safe.
- The 214 sample map records the first LX event per transaction into a flat status table and always inserts (no match key), so repeated statuses form a history; a host that wants every LX event maps an association.
- Demo: Harbor Logistics gets a gateway OUTBOX transport for 945s (the partner pulls over SFTP, per 5.3) after the retail seed has run, so its earlier acknowledgements keep their visible retries; the fourth partner Ridgeline Freight is added, so `EdiDemoServerTest` now expects four partners.
||||||| parent of bfb63ce (feat: phase c - AS2 transport)
## Phase c implementation notes (2026-10-14)
- as2-lib's decrypt and verify write temporary files and its receive helpers need servlet classes, so only
its enum tables, `MIC`, `DispositionOptions` and signer are used; the rest is BouncyCastle in memory
(section 5.5). Its dependency tree pulls `bcjmail` 1.85 (Jakarta Mail), so `bcpkix` is pinned to 1.85 and
promoted from test scope; no other QQQ dependency changed.
- The MIC is computed over the exact signed bytes (headers included) instead of as2-lib's
decode-then-re-encode, which differs for base64 content; verified against an independent
BouncyCastle peer with base64 and binary transfer encodings.
- Encryption wraps the exact bytes that were hashed (CMS enveloped data over the serialized signed
entity), so the MIC cannot drift between signing and encryption.
- `edi_as2_mdn` doubles as the asynchronous MDN queue (`deliveryStatus`, `deliveryAttempts`,
`nextAttemptAt`) instead of a separate table; the scheduled step is `edi_as2_mdn_sender`.
- New columns: `edi_outbound_message.transport_mic` (the MIC we sent, for asynchronous comparison) and
`edi_transport.weak_crypto`. New config: `EdiQBitConfig.publicBaseUrl`.
- The alert is `edi.mdn.overdue` (RC.5 rejects underscores); `SendWorker` now records the transport
failure reason (value-free by contract: an HTTP status, an AS2 disposition or a variable name) in
`lastError` instead of a generic text.
- Not implemented: EC and RSA-PSS signing keys (RSA only), MIC comparison under a different algorithm than
the one requested, CAQH/Peppol profiles, and AS2 over a host-mounted client certificate (`MTLS`).
+28 -1
View File
@@ -55,6 +55,31 @@ CREATE TABLE edi_agreement_document (
UNIQUE (agreement_id, direction, transaction_set, version)
);
CREATE TABLE edi_as2_mdn (
create_date TIMESTAMP,
delivered_at TIMESTAMP,
delivery_attempts INTEGER,
delivery_status VARCHAR(20),
delivery_url VARCHAR(1000),
direction VARCHAR(20),
disposition VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
inbox_id INTEGER,
message_id VARCHAR(255),
mic_algorithm VARCHAR(50),
mic_value VARCHAR(255),
modify_date TIMESTAMP,
next_attempt_at TIMESTAMP,
original_message_id VARCHAR(255),
outbound_message_id INTEGER,
partner_id INTEGER,
payload_ref VARCHAR(255),
received_at TIMESTAMP,
signature_valid BOOLEAN,
signed BOOLEAN,
transport_id INTEGER
);
CREATE TABLE edi_certificate (
alias VARCHAR(255),
certificate_pem VARCHAR(10000),
@@ -388,7 +413,8 @@ CREATE TABLE edi_outbound_message (
size_bytes BIGINT,
status VARCHAR(255),
transport_id INTEGER,
transport_message_id VARCHAR(255)
transport_message_id VARCHAR(255),
transport_mic VARCHAR(255)
);
CREATE TABLE edi_outbound_request (
@@ -536,5 +562,6 @@ CREATE TABLE edi_transport (
sign_algorithm VARCHAR(255),
type VARCHAR(255),
van_name VARCHAR(255),
weak_crypto BOOLEAN,
UNIQUE (name)
);
+2 -1
View File
@@ -69,7 +69,8 @@
<dependencies>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.14</version><scope>test</scope></dependency>
<dependency><groupId>org.bouncycastle</groupId><artifactId>bcpkix-jdk18on</artifactId><version>1.81</version><scope>test</scope></dependency>
<dependency><groupId>org.bouncycastle</groupId><artifactId>bcpkix-jdk18on</artifactId><version>1.85</version></dependency>
<dependency><groupId>com.helger.as2</groupId><artifactId>as2-lib</artifactId><version>5.1.8</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-filesystem</artifactId></dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
@@ -68,6 +68,7 @@ public class EdiQBitConfig implements QBitConfig
private String appName = "edi";
private String appLabel = "EDI";
private String parentAppName;
private String publicBaseUrl;
@@ -350,4 +351,23 @@ public class EdiQBitConfig implements QBitConfig
esbDestinationName = value;
return (this);
}
/*******************************************************************************
** Public base URL of this deployment (for example https://edi.example.com),
** used to build the asynchronous AS2 receipt address
** {base}/qqq/hooks/edi/as2/{transport}/mdn.
*******************************************************************************/
public String getPublicBaseUrl()
{
return (publicBaseUrl);
}
/*******************************************************************************
** Set the public base URL.
*******************************************************************************/
public EdiQBitConfig withPublicBaseUrl(String value)
{
publicBaseUrl = value;
return (this);
}
}
@@ -135,11 +135,12 @@ public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData()
.withPlugin(new com.kingsrook.qbits.edi.transport.gateway.GatewayTransport())
.withPlugin(new com.kingsrook.qbits.edi.transport.HttpsTransport()).withPlugin(new com.kingsrook.qbits.edi.transport.SftpTransport())
.withPlugin(new com.kingsrook.qbits.edi.transport.UnavailableTransport("AS2")).withPlugin(new com.kingsrook.qbits.edi.transport.UnavailableTransport("EMAIL")));
.withPlugin(new com.kingsrook.qbits.edi.as2.As2Transport()).withPlugin(new com.kingsrook.qbits.edi.transport.UnavailableTransport("EMAIL")));
try
{
com.kingsrook.qqq.middleware.javalin.QJavalinMetaData.ofOrWithNew(instance).withAdditionalRouteProviderReference(
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.transport.HttpsRouteProvider.class));
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.transport.HttpsRouteProvider.class))
.withAdditionalRouteProviderReference(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.as2.As2RouteProvider.class));
}
catch(NoClassDefFoundError optionalMiddlewareAbsent)
{
@@ -0,0 +1,646 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import com.helger.as2lib.crypto.BCCryptoHelper;
import com.helger.as2lib.crypto.ECryptoAlgorithmCrypt;
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
import com.helger.as2lib.crypto.MIC;
import com.helger.mail.cte.EContentTransferEncoding;
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
import jakarta.activation.DataHandler;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.internet.MimeUtility;
import jakarta.mail.util.ByteArrayDataSource;
import org.bouncycastle.cms.CMSEnvelopedData;
import org.bouncycastle.cms.CMSEnvelopedDataGenerator;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignerDigestMismatchException;
import org.bouncycastle.cms.RecipientInformation;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
import org.bouncycastle.cms.jcajce.JceCMSContentEncryptorBuilder;
import org.bouncycastle.cms.jcajce.JceKeyTransEnvelopedRecipient;
import org.bouncycastle.cms.jcajce.JceKeyTransRecipientId;
import org.bouncycastle.cms.jcajce.JceKeyTransRecipientInfoGenerator;
import org.bouncycastle.cms.jcajce.ZlibCompressor;
import org.bouncycastle.cms.jcajce.ZlibExpanderProvider;
import org.bouncycastle.mail.smime.SMIMECompressed;
import org.bouncycastle.mail.smime.SMIMECompressedGenerator;
import org.bouncycastle.mail.smime.SMIMEEnveloped;
import org.bouncycastle.mail.smime.SMIMESigned;
/*******************************************************************************
** The AS2 cryptographic layer, entirely in memory. S/MIME signing and the
** algorithm tables use as2-lib's BouncyCastle helper; encryption, decryption,
** signature verification, compression and the MIC use BouncyCastle directly,
** because as2-lib's decrypt and verify stage content in temporary files and this
** qbit never writes local files. The MIC is computed over the exact bytes of the
** signed (or, when unsigned, the transmitted) MIME entity, headers included, so
** both ends agree whatever transfer encoding was used. Messages follow RFC 4130
** (AS2), RFC 5751 (S/MIME) and RFC 5402 (compression).
*******************************************************************************/
public final class As2Crypto
{
private static final BCCryptoHelper HELPER = new BCCryptoHelper();
private static final String PROVIDER = BCCryptoHelper.DEFAULT_SECURITY_PROVIDER_NAME;
private static final byte[] HEADER_END = "\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
private static final byte[] LINE_END = "\r\n".getBytes(StandardCharsets.US_ASCII);
public static final long MAX_EXPANDED_BYTES = 256L * 1024 * 1024;
/*******************************************************************************
** The result of unwrapping an inbound message: the EDI payload, what protection
** was applied and the MIC of the signed (or received) content.
*******************************************************************************/
public record Decoded(byte[] payload, String fileName, Boolean encrypted, Boolean signed, Boolean compressed, MIC mic)
{
}
/*******************************************************************************
** A MIME entity split for HTTP: the Content-Type, transfer encoding and
** disposition go in HTTP headers, the body is the HTTP body.
*******************************************************************************/
public record Wire(String contentType, String transferEncoding, String contentDisposition, byte[] body)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private As2Crypto()
{
}
/*******************************************************************************
** Map the signAlgorithm setting; null means unsigned.
*******************************************************************************/
public static ECryptoAlgorithmSign signAlgorithm(String setting)
{
if(setting == null || setting.isBlank())
{
return (null);
}
return (switch(setting)
{
case "SHA256" -> ECryptoAlgorithmSign.DIGEST_SHA_256;
case "SHA384" -> ECryptoAlgorithmSign.DIGEST_SHA_384;
case "SHA512" -> ECryptoAlgorithmSign.DIGEST_SHA_512;
case "SHA1" -> ECryptoAlgorithmSign.DIGEST_SHA_1;
default -> throw new IllegalArgumentException("Unsupported signAlgorithm");
});
}
/*******************************************************************************
** Map the encryptAlgorithm setting; null means unencrypted.
*******************************************************************************/
public static ECryptoAlgorithmCrypt encryptAlgorithm(String setting)
{
if(setting == null || setting.isBlank())
{
return (null);
}
return (switch(setting)
{
case "AES128_CBC" -> ECryptoAlgorithmCrypt.CRYPT_AES128_CBC;
case "AES256_CBC" -> ECryptoAlgorithmCrypt.CRYPT_AES256_CBC;
case "AES128_GCM" -> ECryptoAlgorithmCrypt.CRYPT_AES128_GCM;
case "AES256_GCM" -> ECryptoAlgorithmCrypt.CRYPT_AES256_GCM;
case "3DES" -> ECryptoAlgorithmCrypt.CRYPT_3DES;
default -> throw new IllegalArgumentException("Unsupported encryptAlgorithm");
});
}
/*******************************************************************************
** SHA1 signatures and 3DES encryption are accepted only when set explicitly,
** and are flagged as weak.
*******************************************************************************/
public static boolean isWeak(String sign, String encrypt)
{
return ("SHA1".equals(sign) || "3DES".equals(encrypt));
}
/*******************************************************************************
** Parse a complete MIME entity (headers, blank line, body) held in memory.
*******************************************************************************/
public static MimeBodyPart parse(byte[] entity) throws As2Exception
{
try
{
return (new MimeBodyPart(new ByteArrayInputStream(entity)));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 message is not a valid MIME entity", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Rebuild the MIME entity of an HTTP request or response from its Content-Type,
** Content-Transfer-Encoding and Content-Disposition headers and its body.
*******************************************************************************/
public static MimeBodyPart fromHttp(String contentType, String transferEncoding, String contentDisposition, byte[] body) throws As2Exception
{
return (parse(entity(contentType, transferEncoding, contentDisposition, body)));
}
/*******************************************************************************
** The MIME entity bytes (headers, blank line, body) for HTTP-level parts. Header
** values with line breaks are refused.
*******************************************************************************/
public static byte[] entity(String contentType, String transferEncoding, String contentDisposition, byte[] body) throws As2Exception
{
for(String value : Arrays.asList(contentType, transferEncoding, contentDisposition))
{
if(value != null && (value.contains("\r") || value.contains("\n")))
{
throw (new As2Exception("The AS2 message has an invalid header", As2Exception.UNEXPECTED_PROCESSING));
}
}
if(contentType == null || contentType.isBlank())
{
throw (new As2Exception("The AS2 message has no valid Content-Type", As2Exception.UNEXPECTED_PROCESSING));
}
ByteArrayOutputStream entity = new ByteArrayOutputStream();
String headers = "Content-Type: " + contentType + "\r\n" + (transferEncoding == null || transferEncoding.isBlank() ? "" : "Content-Transfer-Encoding: " + transferEncoding + "\r\n")
+ (contentDisposition == null || contentDisposition.isBlank() ? "" : "Content-Disposition: " + contentDisposition + "\r\n") + "\r\n";
entity.writeBytes(headers.getBytes(StandardCharsets.ISO_8859_1));
entity.writeBytes(body);
return (entity.toByteArray());
}
/*******************************************************************************
** Serialize a MIME entity (headers and encoded body).
*******************************************************************************/
public static byte[] toBytes(MimeBodyPart part) throws As2Exception
{
try
{
ByteArrayOutputStream out = new ByteArrayOutputStream();
part.writeTo(out);
return (out.toByteArray());
}
catch(Exception e)
{
throw (new As2Exception("The AS2 message could not be serialized", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Split a MIME entity into the HTTP headers that describe it and its body.
*******************************************************************************/
public static Wire toWire(MimeBodyPart part) throws As2Exception
{
return (splitRaw(toBytes(part)));
}
/*******************************************************************************
** Split already serialized MIME entity bytes into the Content-Type,
** Content-Transfer-Encoding and Content-Disposition values and the body,
** without re-serializing (which could invalidate a signature).
*******************************************************************************/
public static Wire splitRaw(byte[] bytes) throws As2Exception
{
int split = indexOf(bytes, HEADER_END, 0);
if(split < 0)
{
throw (new As2Exception("The AS2 message has no body", As2Exception.UNEXPECTED_PROCESSING));
}
String contentType = null;
String encoding = null;
String disposition = null;
String current = null;
StringBuilder value = new StringBuilder();
for(String line : (new String(bytes, 0, split, StandardCharsets.ISO_8859_1) + "\r\nX-End: end").split("\r\n"))
{
if((line.startsWith(" ") || line.startsWith("\t")) && current != null)
{
value.append(' ').append(line.trim());
continue;
}
if(current != null)
{
contentType = "content-type".equals(current) ? value.toString() : contentType;
encoding = "content-transfer-encoding".equals(current) ? value.toString() : encoding;
disposition = "content-disposition".equals(current) ? value.toString() : disposition;
}
int colon = line.indexOf(':');
current = colon < 0 ? null : line.substring(0, colon).trim().toLowerCase(java.util.Locale.ROOT);
value = new StringBuilder(colon < 0 ? "" : line.substring(colon + 1).trim());
}
byte[] body = new byte[bytes.length - split - HEADER_END.length];
System.arraycopy(bytes, split + HEADER_END.length, body, 0, body.length);
return (new Wire(contentType, encoding, disposition, body));
}
/*******************************************************************************
** The EDI document as a binary MIME part.
*******************************************************************************/
public static MimeBodyPart payloadPart(byte[] payload, String contentType, String fileName) throws As2Exception
{
try
{
MimeBodyPart part = new MimeBodyPart();
part.setDataHandler(new DataHandler(new ByteArrayDataSource(payload, contentType)));
part.setHeader("Content-Type", contentType);
part.setHeader("Content-Transfer-Encoding", "binary");
part.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
return (part);
}
catch(Exception e)
{
throw (new As2Exception("The EDI document could not be wrapped for AS2", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Compress a part (RFC 5402, zlib).
*******************************************************************************/
public static MimeBodyPart compress(MimeBodyPart part) throws As2Exception
{
try
{
SMIMECompressedGenerator generator = new SMIMECompressedGenerator();
generator.setContentTransferEncoding("binary");
return (generator.generate(part, new ZlibCompressor()));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 message could not be compressed", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** The MIC of a MIME entity: the digest of its exact bytes, headers included
** (RFC 4130 section 7.3.1).
*******************************************************************************/
public static MIC mic(byte[] entity, ECryptoAlgorithmSign algorithm) throws As2Exception
{
try
{
return (new MIC(MessageDigest.getInstance(algorithm.getID().toUpperCase(java.util.Locale.ROOT)).digest(entity), algorithm));
}
catch(Exception e)
{
throw (new As2Exception("The message integrity check could not be computed", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** The exact bytes of the first body part of a multipart entity: the signed
** content of multipart/signed, without the delimiter lines around it.
*******************************************************************************/
public static byte[] firstPart(String contentType, byte[] body) throws As2Exception
{
try
{
String boundary = new ContentType(contentType).getParameter("boundary");
int open = indexOf(body, ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1), 0);
int start = open < 0 ? -1 : indexOf(body, LINE_END, open) + LINE_END.length;
int end = start < 0 ? -1 : indexOf(body, ("\r\n--" + boundary).getBytes(StandardCharsets.ISO_8859_1), start - LINE_END.length);
if(end < 0)
{
throw (new IllegalArgumentException("no signed content"));
}
return (Arrays.copyOfRange(body, start, end));
}
catch(Exception e)
{
throw (new As2Exception("The signed AS2 message is malformed", As2Exception.AUTHENTICATION_FAILED));
}
}
/*******************************************************************************
** Sign a part into multipart/signed with the local key.
*******************************************************************************/
public static MimeBodyPart sign(MimeBodyPart part, LocalKey key, ECryptoAlgorithmSign algorithm) throws As2Exception
{
try
{
return (HELPER.sign(part, key.certificate(), key.key(), algorithm, true, algorithm == ECryptoAlgorithmSign.DIGEST_SHA_1, false, EContentTransferEncoding.BINARY));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 message could not be signed with certificate " + key.row().getValueString("alias") + ": " + e.getClass().getSimpleName(), As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Encrypt the exact bytes of a MIME entity to the partner certificate (CMS
** enveloped data), returning the application/pkcs7-mime wire message.
*******************************************************************************/
public static Wire encrypt(byte[] entity, X509Certificate certificate, ECryptoAlgorithmCrypt algorithm) throws As2Exception
{
try
{
CMSEnvelopedDataGenerator generator = new CMSEnvelopedDataGenerator();
generator.addRecipientInfoGenerator(new JceKeyTransRecipientInfoGenerator(certificate).setProvider(PROVIDER));
CMSEnvelopedData enveloped = generator.generate(new CMSProcessableByteArray(entity), new JceCMSContentEncryptorBuilder(algorithm.getOID()).setProvider(PROVIDER).build());
return (new Wire("application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m", "binary", "attachment; filename=\"smime.p7m\"", enveloped.getEncoded()));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 message could not be encrypted: " + e.getClass().getSimpleName(), As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Unwrap an inbound entity: decrypt with any of our keys, verify the signature
** against the partner certificates, decompress, and compute the MIC over the
** exact signed bytes (or, when unsigned, the entity after decryption). Layers
** may nest in any order (RFC 5402 allows compression before or after signing).
*******************************************************************************/
public static Decoded decode(byte[] entity, List<LocalKey> keys, List<X509Certificate> partnerCertificates, ECryptoAlgorithmSign micAlgorithm, Date now) throws As2Exception
{
byte[] raw = entity;
byte[] micBytes = raw;
MimeBodyPart part = parse(raw);
boolean encrypted = false;
boolean signed = false;
boolean compressed = false;
for(int depth = 0; depth < 6; depth++)
{
if(isEncrypted(part))
{
raw = decrypt(part, keys);
part = parse(raw);
micBytes = signed ? micBytes : raw;
encrypted = true;
}
else if(isSigned(part))
{
verify(part, partnerCertificates, now);
Wire wire = splitRaw(raw);
raw = firstPart(wire.contentType(), wire.body());
micBytes = raw;
part = parse(raw);
signed = true;
}
else if(isCompressed(part))
{
raw = expand(part);
part = parse(raw);
compressed = true;
}
else
{
break;
}
}
try
{
MIC mic = micAlgorithm == null ? null : mic(micBytes, micAlgorithm);
return (new Decoded(part.getInputStream().readAllBytes(), part.getFileName(), encrypted, signed, compressed, mic));
}
catch(As2Exception e)
{
throw (e);
}
catch(Exception e)
{
throw (new As2Exception("The AS2 payload could not be read", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Verify a multipart/signed entity against the candidate certificates and
** return the signed content. A digest that does not match the content is an
** integrity failure; a signature no candidate certificate verifies is an
** authentication failure. Expired certificates are not accepted.
*******************************************************************************/
public static MimeBodyPart verify(MimeBodyPart part, List<X509Certificate> certificates, Date now) throws As2Exception
{
SMIMESigned signed;
try
{
signed = new SMIMESigned(new MimeMultipart(new ByteArrayDataSource(part.getRawInputStream().readAllBytes(), MimeUtility.unfold(part.getContentType()))));
}
catch(Exception e)
{
throw (new As2Exception("The signed AS2 message is malformed", As2Exception.AUTHENTICATION_FAILED));
}
for(SignerInformation signer : signed.getSignerInfos().getSigners())
{
for(X509Certificate certificate : certificates)
{
if(certificate.getNotAfter().before(now))
{
continue;
}
try
{
if(signer.verify(new JcaSimpleSignerInfoVerifierBuilder().setProvider(PROVIDER).build(certificate)))
{
return (signed.getContent());
}
}
catch(CMSSignerDigestMismatchException e)
{
throw (new As2Exception("The message digest does not match the signed content", As2Exception.INTEGRITY_CHECK_FAILED));
}
catch(Exception e)
{
// This certificate cannot verify the signature; try the next.
continue;
}
}
}
throw (new As2Exception("The signature could not be verified with the partner certificates", As2Exception.AUTHENTICATION_FAILED));
}
/*******************************************************************************
** Decrypt with the first of our keys the message was encrypted to, returning
** the clear MIME entity bytes.
*******************************************************************************/
private static byte[] decrypt(MimeBodyPart part, List<LocalKey> keys) throws As2Exception
{
SMIMEEnveloped enveloped;
try
{
enveloped = new SMIMEEnveloped(part);
}
catch(Exception e)
{
throw (new As2Exception("The encrypted AS2 message is malformed", As2Exception.DECRYPTION_FAILED));
}
for(LocalKey key : keys)
{
try
{
RecipientInformation recipient = enveloped.getRecipientInfos().get(new JceKeyTransRecipientId(key.certificate()));
if(recipient != null)
{
return (recipient.getContent(new JceKeyTransEnvelopedRecipient(key.key()).setProvider(PROVIDER)));
}
}
catch(Exception e)
{
// The key matched the recipient but could not decrypt; try the next key.
continue;
}
}
throw (new As2Exception("The message was not encrypted to an accepted certificate", As2Exception.DECRYPTION_FAILED));
}
/*******************************************************************************
** Inflate a compressed-data entity, bounded to prevent decompression bombs.
*******************************************************************************/
private static byte[] expand(MimeBodyPart part) throws As2Exception
{
try
{
return (new SMIMECompressed(part).getContent(new ZlibExpanderProvider(MAX_EXPANDED_BYTES)));
}
catch(Exception e)
{
throw (new As2Exception("The compressed AS2 message could not be expanded", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** True for application/pkcs7-mime enveloped-data.
*******************************************************************************/
private static boolean isEncrypted(MimeBodyPart part) throws As2Exception
{
try
{
return (HELPER.isEncrypted(part));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 Content-Type is invalid", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** True for multipart/signed.
*******************************************************************************/
private static boolean isSigned(MimeBodyPart part) throws As2Exception
{
try
{
return (HELPER.isSigned(part));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 Content-Type is invalid", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** True for application/pkcs7-mime compressed-data.
*******************************************************************************/
private static boolean isCompressed(MimeBodyPart part) throws As2Exception
{
try
{
return (HELPER.isCompressed(part.getContentType()));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 Content-Type is invalid", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Index of a byte sequence at or after a position, or -1.
*******************************************************************************/
private static int indexOf(byte[] data, byte[] pattern, int from)
{
for(int i = Math.max(from, 0); i <= data.length - pattern.length; i++)
{
int j = 0;
while(j < pattern.length && data[i + j] == pattern[j])
{
j++;
}
if(j == pattern.length)
{
return (i);
}
}
return (-1);
}
}
@@ -0,0 +1,69 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
/*******************************************************************************
** A plain, value-free AS2 failure. The message never carries key material,
** secrets or payload content; the optional code is the RFC 4130 disposition
** modifier a receiver reports to the sender (for example authentication-failed).
*******************************************************************************/
public class As2Exception extends Exception
{
public static final String DECRYPTION_FAILED = "decryption-failed";
public static final String AUTHENTICATION_FAILED = "authentication-failed";
public static final String INTEGRITY_CHECK_FAILED = "integrity-check-failed";
public static final String INSUFFICIENT_SECURITY = "insufficient-message-security";
public static final String UNEXPECTED_PROCESSING = "unexpected-processing-error";
private final String code;
/*******************************************************************************
** Failure without a disposition code (configuration and local problems).
*******************************************************************************/
public As2Exception(String message)
{
this(message, null);
}
/*******************************************************************************
** Failure carrying the RFC 4130 disposition modifier.
*******************************************************************************/
public As2Exception(String message, String code)
{
super(message);
this.code = code;
}
/*******************************************************************************
** The RFC 4130 disposition modifier, or null when the failure is local.
*******************************************************************************/
public String getCode()
{
return (code);
}
}
@@ -0,0 +1,234 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
/*******************************************************************************
** HTTP client helpers for AS2: bounded timeouts, no redirects, optional pinning
** of the partner TLS certificate (otherwise the JVM trust store applies), and
** bounded response bodies.
*******************************************************************************/
public final class As2Http
{
public static final int MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
/*******************************************************************************
** Status, lower-case header names and the (bounded) body of a response.
*******************************************************************************/
public record Response(int status, Map<String, String> headers, byte[] body)
{
}
/*******************************************************************************
** Accepts only the pinned certificate as the server leaf; the hostname is
** still checked by the TLS stack.
*******************************************************************************/
private static final class PinnedTrustManager implements X509TrustManager
{
private final byte[] pinned;
/*******************************************************************************
** Pin the SHA-256 of the encoded certificate.
*******************************************************************************/
private PinnedTrustManager(X509Certificate certificate) throws Exception
{
pinned = MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded());
}
/*******************************************************************************
** This client never authenticates with a client certificate.
*******************************************************************************/
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException
{
throw (new CertificateException("Client certificates are not accepted"));
}
/*******************************************************************************
** Require the leaf to be exactly the pinned certificate.
*******************************************************************************/
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException
{
try
{
if(chain == null || chain.length == 0 || !MessageDigest.isEqual(pinned, MessageDigest.getInstance("SHA-256").digest(chain[0].getEncoded())))
{
throw (new CertificateException("The server certificate does not match the pinned partner TLS certificate"));
}
}
catch(CertificateException e)
{
throw (e);
}
catch(Exception e)
{
throw (new CertificateException("The server certificate could not be checked against the pin"));
}
}
/*******************************************************************************
** No trust anchors beyond the pin.
*******************************************************************************/
@Override
public X509Certificate[] getAcceptedIssuers()
{
return (new X509Certificate[0]);
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private As2Http()
{
}
/*******************************************************************************
** A client using the JVM trust store, or only the pinned certificate.
*******************************************************************************/
public static HttpClient client(X509Certificate pinned) throws Exception
{
HttpClient.Builder builder = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).followRedirects(HttpClient.Redirect.NEVER);
if(pinned != null)
{
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[] {new PinnedTrustManager(pinned)}, new SecureRandom());
builder.sslContext(context);
}
return (builder.build());
}
/*******************************************************************************
** Trust manager for pin checks in tests and diagnostics.
*******************************************************************************/
public static X509TrustManager pinnedTrustManager(X509Certificate pinned) throws Exception
{
return (new PinnedTrustManager(pinned));
}
/*******************************************************************************
** POST bytes with the given headers.
*******************************************************************************/
public static Response post(HttpClient client, URI uri, Map<String, String> headers, byte[] body, Duration timeout) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(uri).timeout(timeout).POST(HttpRequest.BodyPublishers.ofByteArray(body));
headers.forEach(request::header);
return (read(client.send(request.build(), HttpResponse.BodyHandlers.ofInputStream())));
}
/*******************************************************************************
** OPTIONS probe: no body is sent.
*******************************************************************************/
public static Response options(HttpClient client, URI uri, Map<String, String> headers, Duration timeout) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(uri).timeout(timeout).method("OPTIONS", HttpRequest.BodyPublishers.noBody());
headers.forEach(request::header);
return (read(client.send(request.build(), HttpResponse.BodyHandlers.ofInputStream())));
}
/*******************************************************************************
** Read the bounded body and lower-case the header names.
*******************************************************************************/
private static Response read(HttpResponse<InputStream> response) throws Exception
{
try(InputStream stream = response.body())
{
Map<String, String> headers = new TreeMap<>();
response.headers().map().forEach((name, values) -> headers.put(name.toLowerCase(Locale.ROOT), values.getFirst()));
return (new Response(response.statusCode(), headers, stream.readNBytes(MAX_RESPONSE_BYTES)));
}
}
/*******************************************************************************
** An Authorization header value for HTTP Basic authentication.
*******************************************************************************/
public static String basic(String user, String password)
{
return ("Basic " + Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8)));
}
/*******************************************************************************
** TLS is required except on loopback, which the tests and local peers use.
*******************************************************************************/
public static boolean isAllowedUrl(String url)
{
try
{
URI uri = URI.create(url);
return (uri.getHost() != null && uri.getUserInfo() == null
&& ("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()) && List.of("127.0.0.1", "localhost", "[::1]").contains(uri.getHost())));
}
catch(Exception e)
{
return (false);
}
}
}
@@ -0,0 +1,261 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.List;
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
import com.helger.as2lib.crypto.MIC;
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
import jakarta.mail.internet.InternetHeaders;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.internet.MimeUtility;
import jakarta.mail.util.ByteArrayDataSource;
/*******************************************************************************
** Builds and parses AS2 message disposition notifications (RFC 4130 section 7,
** RFC 8098): multipart/report with a message/disposition-notification part,
** optionally wrapped in multipart/signed. Memory only.
*******************************************************************************/
public final class As2Mdn
{
public static final String PROCESSED = "automatic-action/MDN-sent-automatically; processed";
/*******************************************************************************
** A receipt ready to send or store: HTTP-level parts and the complete MIME
** entity (headers and body) kept as the stored raw MDN.
*******************************************************************************/
public record Built(String messageId, As2Crypto.Wire wire, byte[] entity, Boolean signed)
{
}
/*******************************************************************************
** A receipt read from a partner. Fields come from the disposition notification;
** signed and signatureValid describe the signature, when there was one.
*******************************************************************************/
public record Parsed(String originalMessageId, String disposition, String micText, MIC mic, Boolean signed, Boolean signatureValid)
{
/*******************************************************************************
** Success means the partner processed the message (a warning still counts).
*******************************************************************************/
public boolean processed()
{
if(disposition == null)
{
return (false);
}
int separator = disposition.indexOf(';');
String status = (separator < 0 ? disposition : disposition.substring(separator + 1)).trim().toLowerCase(java.util.Locale.ROOT);
return (status.equals("processed") || status.startsWith("processed/warning"));
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private As2Mdn()
{
}
/*******************************************************************************
** Disposition text for a failure, e.g. processed/error: authentication-failed.
*******************************************************************************/
public static String failure(String code)
{
return ("automatic-action/MDN-sent-automatically; processed/error: " + code);
}
/*******************************************************************************
** Normalize an AS2 identifier or Message-ID: trim, drop angle brackets and
** quotes.
*******************************************************************************/
public static String normalizeId(String value)
{
if(value == null)
{
return (null);
}
String id = value.trim();
while(id.length() >= 2 && ((id.startsWith("<") && id.endsWith(">")) || (id.startsWith("\"") && id.endsWith("\""))))
{
id = id.substring(1, id.length() - 1).trim();
}
return (id);
}
/*******************************************************************************
** Keep only characters that are safe to show and store from a remote string.
*******************************************************************************/
public static String sanitize(String value)
{
if(value == null)
{
return (null);
}
String clean = value.replaceAll("[^A-Za-z0-9 /;:._,=+@<>-]", "");
return (clean.length() <= 200 ? clean : clean.substring(0, 200));
}
/*******************************************************************************
** Build a receipt. The MIC is included for processed messages; a signing key
** and algorithm produce a signed receipt.
*******************************************************************************/
public static Built build(String messageId, String ourId, String originalMessageId, String disposition, MIC mic, LocalKey key, ECryptoAlgorithmSign algorithm) throws As2Exception
{
try
{
String fields = "Reporting-UA: qbit-edi\r\nOriginal-Recipient: rfc822; " + ourId + "\r\nFinal-Recipient: rfc822; " + ourId
+ "\r\nOriginal-Message-ID: <" + originalMessageId + ">\r\nDisposition: " + disposition + "\r\n"
+ (mic == null ? "" : "Received-Content-MIC: " + mic.getAsAS2String() + "\r\n");
MimeMultipart report = new MimeMultipart("report; report-type=disposition-notification");
MimeBodyPart text = new MimeBodyPart();
text.setText("The AS2 message " + originalMessageId + " was processed with disposition: " + disposition + "\r\n", "us-ascii");
text.setHeader("Content-Transfer-Encoding", "7bit");
report.addBodyPart(text);
InternetHeaders headers = new InternetHeaders();
headers.addHeader("Content-Type", "message/disposition-notification");
headers.addHeader("Content-Transfer-Encoding", "7bit");
report.addBodyPart(new MimeBodyPart(headers, fields.getBytes(StandardCharsets.US_ASCII)));
MimeBodyPart outer = new MimeBodyPart();
outer.setContent(report);
outer.setHeader("Content-Type", report.getContentType());
MimeBodyPart result = key == null ? outer : As2Crypto.sign(outer, key, algorithm);
byte[] entity = As2Crypto.toBytes(result);
return (new Built(messageId, As2Crypto.splitRaw(entity), entity, key != null));
}
catch(As2Exception e)
{
throw (e);
}
catch(Exception e)
{
throw (new As2Exception("The MDN could not be built", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Parse a receipt. A signature is verified against the candidate certificates
** and reported in signatureValid; an invalid one still yields the readable
** content so the failure can be recorded, but callers must not trust it.
*******************************************************************************/
public static Parsed parse(String contentType, String transferEncoding, byte[] body, List<X509Certificate> certificates, Date now) throws As2Exception
{
MimeBodyPart part = As2Crypto.fromHttp(contentType, transferEncoding, null, body);
boolean signed = false;
Boolean valid = null;
try
{
MimeBodyPart content = part;
if(part.isMimeType("multipart/signed"))
{
signed = true;
try
{
content = As2Crypto.verify(part, certificates, now);
valid = true;
}
catch(As2Exception e)
{
valid = false;
content = (MimeBodyPart) new MimeMultipart(new ByteArrayDataSource(part.getRawInputStream().readAllBytes(), MimeUtility.unfold(part.getContentType()))).getBodyPart(0);
}
}
if(!content.isMimeType("multipart/report"))
{
throw (new As2Exception("The response is not a message disposition notification"));
}
MimeMultipart report = new MimeMultipart(new ByteArrayDataSource(content.getRawInputStream().readAllBytes(), MimeUtility.unfold(content.getContentType())));
for(int i = 0; i < report.getCount(); i++)
{
if(report.getBodyPart(i).isMimeType("message/disposition-notification"))
{
InternetHeaders fields = new InternetHeaders(new ByteArrayInputStream(report.getBodyPart(i).getInputStream().readAllBytes()));
return (fromFields(fields, signed, valid));
}
}
throw (new As2Exception("The response has no disposition notification"));
}
catch(As2Exception e)
{
throw (e);
}
catch(Exception e)
{
throw (new As2Exception("The response is not a readable message disposition notification"));
}
}
/*******************************************************************************
** Read the notification fields.
*******************************************************************************/
private static Parsed fromFields(InternetHeaders fields, boolean signed, Boolean valid)
{
String original = normalizeId(first(fields, "Original-Message-ID"));
String disposition = sanitize(first(fields, "Disposition"));
String micText = sanitize(first(fields, "Received-Content-MIC"));
MIC mic = null;
try
{
mic = MIC.parse(micText);
}
catch(Exception e)
{
// An unreadable MIC is reported as a mismatch by the caller.
mic = null;
}
return (new Parsed(original == null ? null : sanitize(original), disposition, micText, mic, signed, valid));
}
/*******************************************************************************
** First value of a notification field, unfolded, or null.
*******************************************************************************/
private static String first(InternetHeaders fields, String name)
{
String[] values = fields.getHeader(name);
return (values == null || values.length == 0 ? null : MimeUtility.unfold(values[0]).trim());
}
}
@@ -0,0 +1,163 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.List;
import java.util.Map;
import com.helger.as2lib.crypto.MIC;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.flow.EdiJournal;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Receives asynchronous MDNs for messages we sent: parses the receipt, verifies
** its signature against the partner certificates, matches Original-Message-ID to
** an outbound message, compares the MIC, then marks the message DELIVERED or
** FAILED, stores the raw MDN, writes a timeline event and resolves the overdue
** alert. Callers run inside the system context.
*******************************************************************************/
public class As2MdnReceiver
{
private static final QLogger LOG = QLogger.getLogger(As2MdnReceiver.class);
/*******************************************************************************
** Handle one inbound MDN; header names are lower case. A receipt that cannot be
** authenticated changes nothing.
*******************************************************************************/
public As2Receiver.Result receive(EdiTransport transport, Map<String, String> headers, byte[] body) throws Exception
{
String from = As2Mdn.normalizeId(headers.get("as2-from"));
String to = As2Mdn.normalizeId(headers.get("as2-to"));
if(from == null || to == null || !from.equalsIgnoreCase(transport.getAs2PartnerId()) || !to.equalsIgnoreCase(transport.getAs2LocalId()))
{
LOG.warn("AS2 MDN refused for transport " + transport.getName() + ": unknown AS2 identity");
return (As2Receiver.Result.text(403, "Unknown AS2 identity"));
}
As2Mdn.Parsed mdn;
try
{
List<X509Certificate> certificates = transport.getPartnerCertificateId() == null ? List.of() : new CertificateService().partnerVerificationCertificates(transport);
mdn = As2Mdn.parse(headers.get("content-type"), headers.get("content-transfer-encoding"), body, certificates, Date.from(EdiRuntime.current().config().getClock().instant()));
}
catch(As2Exception e)
{
return (As2Receiver.Result.text(400, "The MDN could not be read"));
}
if((Boolean.TRUE.equals(mdn.signed()) && !Boolean.TRUE.equals(mdn.signatureValid())) || (Boolean.TRUE.equals(transport.getMdnSigned()) && !Boolean.TRUE.equals(mdn.signed())))
{
LOG.warn("AS2 MDN refused for transport " + transport.getName() + ": signature check failed");
return (As2Receiver.Result.text(401, "The MDN signature could not be verified"));
}
List<QRecord> messages = mdn.originalMessageId() == null ? List.of() : EdiRecords.query("edi_outbound_message", EdiRecords.equal("transportMessageId", mdn.originalMessageId())
.withCriteria("transportId", QCriteriaOperator.EQUALS, transport.getId()));
if(messages.isEmpty())
{
return (As2Receiver.Result.text(404, "No message was sent with that Original-Message-ID"));
}
String mdnId = As2Mdn.sanitize(As2Mdn.normalizeId(headers.get("message-id")));
if(mdnId != null && !EdiRecords.query(As2Store.TABLE, EdiRecords.equal("direction", "INBOUND").withCriteria("messageId", QCriteriaOperator.EQUALS, mdnId)
.withCriteria("originalMessageId", QCriteriaOperator.EQUALS, mdn.originalMessageId())).isEmpty())
{
return (new As2Receiver.Result(200, Map.of(), new byte[0]));
}
settle(transport, messages.getFirst(), mdn, mdnId, headers, body);
return (new As2Receiver.Result(200, Map.of(), new byte[0]));
}
/*******************************************************************************
** Record the receipt and settle the message it answers.
*******************************************************************************/
private void settle(EdiTransport transport, QRecord message, As2Mdn.Parsed mdn, String mdnId, Map<String, String> headers, byte[] body) throws Exception
{
MIC ours = null;
try
{
ours = MIC.parse(message.getValueString("transportMic"));
}
catch(Exception e)
{
LOG.warn("The MIC stored for outbound message " + message.getValue("id") + " could not be read");
}
String failure = As2Transport.judge(transport, mdn, ours);
byte[] raw = As2Crypto.entity(headers.get("content-type"), headers.get("content-transfer-encoding"), null, body);
SystemWrites.transaction(() ->
{
PayloadRef ref = As2Store.storeRaw(raw);
As2Store.insert(new QRecord().withValue("direction", "INBOUND").withValue("messageId", mdnId).withValue("originalMessageId", mdn.originalMessageId())
.withValue("disposition", mdn.disposition()).withValue("micAlgorithm", mdn.mic() == null ? null : mdn.mic().getDigestAlgorithm().getID())
.withValue("micValue", As2Store.micValue(mdn.mic())).withValue("signed", mdn.signed()).withValue("signatureValid", mdn.signatureValid()).withValue("payloadRef", ref.value())
.withValue("transportId", transport.getId()).withValue("partnerId", message.getValue("partnerId")).withValue("outboundMessageId", message.getValue("id")).withValue("deliveryStatus", "NONE"));
Integer interchangeId = message.getValueInteger("interchangeId");
EdiJournal.event(null, interchangeId, null, "MDN");
if(SystemWrites.guard("UPDATE edi_outbound_message SET id=id WHERE id=? AND status='SENT'", message.getValue("id")))
{
var now = EdiRuntime.current().config().getClock().instant();
String status = failure == null ? "DELIVERED" : "FAILED";
QRecord update = new QRecord().withValue("id", message.getValue("id")).withValue("status", status).withValue("lastError", failure == null ? null : truncate(failure));
if(failure == null)
{
update.withValue("deliveredAt", now);
}
SystemWrites.update("edi_outbound_message", update);
if(interchangeId != null)
{
SystemWrites.update("edi_interchange", new QRecord().withValue("id", interchangeId).withValue("status", status));
}
EdiJournal.event(null, interchangeId, null, status);
if(failure != null)
{
EdiJournal.exception(null, interchangeId, null, message.getValueInteger("partnerId"), "TRANSPORT", "Outbound delivery failed");
QAlerts.raise(new AlertSignal("edi.send.failed").withSeverity(AlertSeverity.ERROR).withDedupKey("edi_outbound_message:" + message.getValue("id"))
.withTitle("EDI delivery failed").withSummary("Review the outbound message"));
}
}
return (null);
});
QAlerts.resolve("edi.mdn.overdue", "edi_outbound_message:" + message.getValue("id"), "MDN received");
}
/*******************************************************************************
** Keep an error within its column.
*******************************************************************************/
private static String truncate(String text)
{
return (text.length() <= 255 ? text : text.substring(0, 255));
}
}
@@ -0,0 +1,158 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.flow.OutboundWorker;
import com.kingsrook.qbits.edi.flow.SendWorker;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Delivers the asynchronous MDNs we owe partners. The edi_as2_mdn row is the
** queue entry (deliveryStatus PENDING): a scheduled step posts the stored MDN to
** the address the partner gave in Receipt-Delivery-Option, retries with the send
** backoff, and fails (with an alert) after the transport maxAttempts. Row locks
** fence concurrent senders.
*******************************************************************************/
public class As2MdnSender
{
private static final QLogger LOG = QLogger.getLogger(As2MdnSender.class);
/*******************************************************************************
** Deliver pending MDNs that are due; returns how many were attempted.
*******************************************************************************/
public Integer runOnce() throws QException
{
return (SystemWrites.call(() ->
{
int count = 0;
var due = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("nextAttemptAt", QCriteriaOperator.IS_BLANK)
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, EdiRuntime.current().config().getClock().instant());
for(QRecord row : EdiRecords.query(As2Store.TABLE, new QQueryFilter().withCriteria("deliveryStatus", QCriteriaOperator.EQUALS, "PENDING").withSubFilter(due).withLimit(20)))
{
count += deliver(row);
}
return (count);
}));
}
/*******************************************************************************
** One delivery attempt, fenced by a row lock on the selected state.
*******************************************************************************/
private Integer deliver(QRecord selected) throws QException
{
return (SystemWrites.transaction(() ->
{
if(!SystemWrites.guard("UPDATE edi_as2_mdn SET id=id WHERE id=? AND delivery_status='PENDING' AND COALESCE(delivery_attempts,0)=?", selected.getValue("id"),
Objects.requireNonNullElse(selected.getValueInteger("deliveryAttempts"), 0)))
{
return (0);
}
QRecord row = OutboundWorker.row(As2Store.TABLE, selected.getValue("id"));
EdiTransport transport = OutboundWorker.row("edi_transport", row.getValue("transportId")).toEntity(EdiTransport.class);
int attempts = Objects.requireNonNullElse(row.getValueInteger("deliveryAttempts"), 0) + 1;
boolean delivered = post(row, transport);
int maximum = Objects.requireNonNullElse(transport.getMaxAttempts(), 8);
Instant now = EdiRuntime.current().config().getClock().instant();
String status = delivered ? "SENT" : attempts >= maximum ? "FAILED" : "PENDING";
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("deliveryStatus", status).withValue("deliveryAttempts", attempts)
.withValue("nextAttemptAt", "PENDING".equals(status) ? now.plusSeconds(SendWorker.BACKOFF.get(Math.min(attempts - 1, SendWorker.BACKOFF.size() - 1))) : null);
if(delivered)
{
update.withValue("deliveredAt", now);
}
SystemWrites.update(As2Store.TABLE, update);
if("FAILED".equals(status))
{
QAlerts.raise(new AlertSignal("edi.send.failed").withSeverity(AlertSeverity.ERROR).withDedupKey("edi_as2_mdn:" + row.getValue("id"))
.withTitle("AS2 receipt delivery failed").withSummary("Review the AS2 receipt"));
}
return (1);
}));
}
/*******************************************************************************
** POST the stored MDN to the partner receipt address; true on any 2xx.
*******************************************************************************/
private boolean post(QRecord row, EdiTransport transport)
{
try
{
byte[] raw;
try(InputStream stream = EdiRuntime.current().payloads().open(new PayloadRef(row.getValueString("payloadRef"), null, 0)))
{
raw = stream.readAllBytes();
}
As2Crypto.Wire wire = As2Crypto.splitRaw(raw);
Map<String, String> headers = new LinkedHashMap<>();
headers.put("AS2-Version", "1.2");
headers.put("AS2-From", As2Transport.asId(transport.getAs2LocalId()));
headers.put("AS2-To", As2Transport.asId(transport.getAs2PartnerId()));
headers.put("Message-ID", "<" + row.getValueString("messageId") + ">");
headers.put("MIME-Version", "1.0");
headers.put("Content-Type", wire.contentType());
if(wire.transferEncoding() != null)
{
headers.put("Content-Transfer-Encoding", wire.transferEncoding());
}
if(transport.getHttpAuthUserEnvVar() != null)
{
var secrets = EdiRuntime.current().config().getSecretsResolver();
headers.put("Authorization", As2Http.basic(secrets.resolve(transport.getHttpAuthUserEnvVar()), secrets.resolve(transport.getHttpAuthPasswordEnvVar())));
}
HttpClient client = As2Http.client(null);
int status = As2Http.post(client, URI.create(row.getValueString("deliveryUrl")), headers, wire.body(), Duration.ofSeconds(60)).status();
return (status >= 200 && status < 300);
}
catch(Exception e)
{
LOG.warn("AS2 receipt delivery attempt failed for receipt " + row.getValue("id"));
return (false);
}
}
}
@@ -0,0 +1,356 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
import com.helger.as2lib.crypto.MIC;
import com.helger.as2lib.disposition.DispositionOptions;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
import com.kingsrook.qbits.edi.flow.EdiJournal;
import com.kingsrook.qbits.edi.flow.InboundPayload;
import com.kingsrook.qbits.edi.flow.InboundSink;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import jakarta.mail.internet.MimeBodyPart;
/*******************************************************************************
** Receives AS2 messages for one transport: checks the AS2 identities, decrypts
** with our ACTIVE or NEXT keys, verifies the signature against the partner
** certificates, computes the MIC, hands the document to the inbox, and answers
** with a synchronous MDN or queues an asynchronous one. A retransmitted
** Message-ID gets the original MDN again and is not reprocessed. Callers run
** inside the system context; failures are reported as RFC 4130 dispositions.
*******************************************************************************/
public class As2Receiver
{
private static final QLogger LOG = QLogger.getLogger(As2Receiver.class);
/*******************************************************************************
** An HTTP answer: status, headers and body.
*******************************************************************************/
public record Result(int status, Map<String, String> headers, byte[] body)
{
/*******************************************************************************
** A plain-text answer.
*******************************************************************************/
static Result text(int status, String text)
{
return (new Result(status, Map.of("Content-Type", "text/plain; charset=utf-8"), text.getBytes(StandardCharsets.UTF_8)));
}
}
/*******************************************************************************
** What was asked of us for this message.
*******************************************************************************/
private record Request(String messageId, boolean mdnRequested, String deliveryUrl, boolean signMdn, ECryptoAlgorithmSign micAlgorithm)
{
/*******************************************************************************
** The same request, answered in the HTTP response only. Used before the sender
** is authenticated, so an unauthenticated sender cannot aim a receipt at an
** address of its choosing.
*******************************************************************************/
Request synchronous()
{
return (new Request(messageId, mdnRequested, null, signMdn, micAlgorithm));
}
}
/*******************************************************************************
** Handle one inbound message; header names are lower case.
*******************************************************************************/
public Result receive(EdiTransport transport, Map<String, String> headers, byte[] body, String remoteAddress) throws Exception
{
String messageId = As2Mdn.normalizeId(headers.get("message-id"));
String from = As2Mdn.normalizeId(headers.get("as2-from"));
String to = As2Mdn.normalizeId(headers.get("as2-to"));
if(messageId == null || messageId.isBlank() || messageId.length() > 250 || !messageId.matches("[\\x21-\\x7E]+") || from == null || to == null)
{
return (Result.text(400, "The AS2-From, AS2-To and Message-ID headers are required"));
}
if(!from.equalsIgnoreCase(transport.getAs2PartnerId()) || !to.equalsIgnoreCase(transport.getAs2LocalId()))
{
LOG.warn("AS2 message refused for transport " + transport.getName() + ": unknown AS2 identity");
return (Result.text(403, "Unknown AS2 identity"));
}
Request request = request(transport, headers, messageId);
if(request.deliveryUrl() != null && !deliveryUrlAllowed(transport, request.deliveryUrl()))
{
return (Result.text(400, "The Receipt-Delivery-Option is not acceptable"));
}
CertificateService certificates = new CertificateService();
CertificateService.KeySet keys = certificates.localDecryptionKeys();
As2Crypto.Decoded decoded;
try
{
List<X509Certificate> partnerCertificates = transport.getPartnerCertificateId() == null ? List.of() : certificates.partnerVerificationCertificates(transport);
byte[] top = As2Crypto.entity(headers.get("content-type"), headers.get("content-transfer-encoding"), headers.get("content-disposition"), body);
decoded = As2Crypto.decode(top, keys.keys(), partnerCertificates, request.micAlgorithm(), Date.from(EdiRuntime.current().config().getClock().instant()));
if((transport.getSignAlgorithm() != null && !decoded.signed()) || (transport.getEncryptAlgorithm() != null && !decoded.encrypted()))
{
throw (new As2Exception("The message does not meet the transport security requirements", As2Exception.INSUFFICIENT_SECURITY));
}
}
catch(As2Exception e)
{
boolean ourFault = e.getCode() == null || (As2Exception.DECRYPTION_FAILED.equals(e.getCode()) && !keys.problems().isEmpty());
return (reject(transport, request.synchronous(), ourFault ? As2Exception.UNEXPECTED_PROCESSING : e.getCode(), certificates));
}
String externalId = externalId(transport, messageId);
QRecord replay = alreadyAnswered(transport, request, externalId);
if(replay != null)
{
return (replay(transport, request, replay));
}
QRecord inbox;
try
{
inbox = new InboundSink(EdiRuntime.current().payloads()).receive(new InboundPayload("AS2", externalId, decoded.fileName(), remoteAddress, new ByteArrayInputStream(decoded.payload())));
if(inbox.getValue("transportId") == null)
{
SystemWrites.update("edi_inbox", new QRecord().withValue("id", inbox.getValue("id")).withValue("transportId", transport.getId()));
}
}
catch(Exception e)
{
LOG.warn("AS2 intake failed for transport " + transport.getName());
return (reject(transport, request, As2Exception.UNEXPECTED_PROCESSING, certificates));
}
if(!request.mdnRequested())
{
return (new Result(200, Map.of(), new byte[0]));
}
try
{
return (answer(transport, request, As2Mdn.PROCESSED, decoded.mic(), inbox.getValueInteger("id"), certificates));
}
catch(As2Exception e)
{
LOG.warn("AS2 receipt could not be produced for transport " + transport.getName() + ": " + e.getMessage());
return (Result.text(500, "The receipt could not be produced"));
}
}
/*******************************************************************************
** Read what the message asks for: whether a receipt is wanted, where an
** asynchronous one goes, and whether and how to sign it.
*******************************************************************************/
private Request request(EdiTransport transport, Map<String, String> headers, String messageId)
{
DispositionOptions options = new DispositionOptions();
try
{
options = DispositionOptions.createFromString(headers.get("disposition-notification-options"));
}
catch(Exception e)
{
LOG.warn("AS2 disposition options ignored for transport " + transport.getName());
}
ECryptoAlgorithmSign algorithm = options.getFirstMICAlg();
if(algorithm == null)
{
algorithm = As2Crypto.signAlgorithm(transport.getSignAlgorithm()) == null ? ECryptoAlgorithmSign.DIGEST_SHA_256 : As2Crypto.signAlgorithm(transport.getSignAlgorithm());
}
String delivery = headers.get("receipt-delivery-option");
return (new Request(messageId, headers.get("disposition-notification-to") != null, delivery == null || delivery.isBlank() ? null : delivery.trim(),
options.getProtocol() != null || Boolean.TRUE.equals(transport.getMdnSigned()), algorithm));
}
/*******************************************************************************
** An asynchronous receipt address must use TLS (loopback excepted) and, unless
** the transport requires signed messages, must be on the partner's own host,
** so an unauthenticated sender cannot aim our receipts at third parties.
*******************************************************************************/
static boolean deliveryUrlAllowed(EdiTransport transport, String url)
{
if(!As2Http.isAllowedUrl(url))
{
return (false);
}
if(transport.getSignAlgorithm() != null)
{
return (true);
}
try
{
return (transport.getAs2Url() != null && java.net.URI.create(url).getHost().equalsIgnoreCase(java.net.URI.create(transport.getAs2Url()).getHost()));
}
catch(Exception e)
{
return (false);
}
}
/*******************************************************************************
** The inbox dedupe key: the Message-ID. Message-IDs are unique per sender, so if
** the same Message-ID was already received on a different transport (another
** partner), the key is scoped by the transport id so neither message is lost.
*******************************************************************************/
private static String externalId(EdiTransport transport, String messageId) throws Exception
{
List<QRecord> rows = EdiRecords.query("edi_inbox", EdiRecords.equal("transportType", "AS2").withCriteria("externalId", QCriteriaOperator.EQUALS, messageId));
boolean foreign = !rows.isEmpty() && rows.getFirst().getValue("transportId") != null && !rows.getFirst().getValue("transportId").equals(transport.getId());
return (foreign ? transport.getId() + ":" + messageId : messageId);
}
/*******************************************************************************
** The receipt row we already sent for this Message-ID (a retransmission), or
** null when the original was never answered.
*******************************************************************************/
private QRecord alreadyAnswered(EdiTransport transport, Request request, String externalId) throws Exception
{
if(EdiRecords.query("edi_inbox", EdiRecords.equal("transportType", "AS2").withCriteria("externalId", QCriteriaOperator.EQUALS, externalId)).isEmpty())
{
return (null);
}
List<QRecord> rows = EdiRecords.query(As2Store.TABLE, EdiRecords.equal("direction", "OUTBOUND").withCriteria("originalMessageId", QCriteriaOperator.EQUALS, request.messageId())
.withCriteria("transportId", QCriteriaOperator.EQUALS, transport.getId()));
return (rows.isEmpty() ? null : rows.getFirst());
}
/*******************************************************************************
** Answer a retransmission with the original receipt, unchanged. An
** asynchronous receipt is queued to be delivered again.
*******************************************************************************/
private Result replay(EdiTransport transport, Request request, QRecord original) throws Exception
{
if(!request.mdnRequested())
{
return (new Result(200, Map.of(), new byte[0]));
}
if(request.deliveryUrl() != null)
{
SystemWrites.update(As2Store.TABLE, new QRecord().withValue("id", original.getValue("id")).withValue("deliveryUrl", request.deliveryUrl()).withValue("deliveryStatus", "PENDING")
.withValue("deliveryAttempts", 0).withValue("nextAttemptAt", null));
return (new Result(200, Map.of(), new byte[0]));
}
byte[] raw;
try(InputStream stream = EdiRuntime.current().payloads().open(new PayloadRef(original.getValueString("payloadRef"), null, 0)))
{
raw = stream.readAllBytes();
}
return (syncResult(transport, original.getValueString("messageId"), As2Crypto.splitRaw(raw)));
}
/*******************************************************************************
** Reject a message that could not be accepted. With a receipt requested the
** failure is reported in an MDN so the partner can act or retry; otherwise an
** HTTP error answers. No inbox row exists for a rejected message.
*******************************************************************************/
private Result reject(EdiTransport transport, Request request, String code, CertificateService certificates) throws Exception
{
LOG.warn("AS2 message rejected for transport " + transport.getName() + ": " + code);
if(!request.mdnRequested())
{
return (Result.text(As2Exception.UNEXPECTED_PROCESSING.equals(code) ? 500 : 400, "AS2 message rejected: " + code));
}
try
{
return (answer(transport, request, As2Mdn.failure(code), null, null, certificates));
}
catch(As2Exception e)
{
LOG.warn("AS2 receipt could not be produced for transport " + transport.getName() + ": " + e.getMessage());
return (Result.text(500, "The receipt could not be produced"));
}
}
/*******************************************************************************
** Build, store and answer or queue the MDN.
*******************************************************************************/
private Result answer(EdiTransport transport, Request request, String disposition, MIC mic, Integer inboxId, CertificateService certificates) throws Exception
{
LocalKey key = request.signMdn() ? certificates.activeLocal(transport) : null;
String mdnId = "edi-mdn-" + UUID.randomUUID() + "@" + transport.getAs2LocalId().replaceAll("[^A-Za-z0-9.-]", "-");
As2Mdn.Built built = As2Mdn.build(mdnId, transport.getAs2LocalId(), request.messageId(), disposition, mic, key, request.micAlgorithm());
PayloadRef raw = As2Store.storeRaw(built.entity());
Instant now = EdiRuntime.current().config().getClock().instant();
QRecord row = As2Store.insert(new QRecord().withValue("direction", "OUTBOUND").withValue("messageId", mdnId).withValue("originalMessageId", request.messageId())
.withValue("disposition", disposition).withValue("micAlgorithm", mic == null ? null : mic.getDigestAlgorithm().getID()).withValue("micValue", As2Store.micValue(mic))
.withValue("signed", built.signed()).withValue("payloadRef", raw.value()).withValue("receivedAt", now).withValue("transportId", transport.getId())
.withValue("partnerId", transport.getPartnerId()).withValue("inboxId", inboxId).withValue("deliveryStatus", request.deliveryUrl() == null ? "NONE" : "PENDING")
.withValue("deliveryUrl", request.deliveryUrl()).withValue("deliveryAttempts", 0).withValue("nextAttemptAt", null));
if(inboxId != null)
{
EdiJournal.event(inboxId, null, null, "MDN");
}
LOG.info("AS2 receipt " + row.getValue("id") + " " + (request.deliveryUrl() == null ? "returned" : "queued") + " for transport " + transport.getName());
return (request.deliveryUrl() != null ? new Result(200, Map.of(), new byte[0]) : syncResult(transport, mdnId, built.wire()));
}
/*******************************************************************************
** The synchronous MDN response with its AS2 headers.
*******************************************************************************/
private Result syncResult(EdiTransport transport, String mdnId, As2Crypto.Wire wire)
{
Map<String, String> headers = new LinkedHashMap<>();
headers.put("AS2-Version", "1.2");
headers.put("AS2-From", As2Transport.asId(transport.getAs2LocalId()));
headers.put("AS2-To", As2Transport.asId(transport.getAs2PartnerId()));
headers.put("Message-ID", "<" + mdnId + ">");
headers.put("MIME-Version", "1.0");
headers.put("Content-Type", wire.contentType());
if(wire.transferEncoding() != null)
{
headers.put("Content-Transfer-Encoding", wire.transferEncoding());
}
return (new Result(200, headers, wire.body()));
}
}
@@ -0,0 +1,148 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import io.javalin.config.JavalinConfig;
import io.javalin.http.Context;
/*******************************************************************************
** AS2 machine endpoints outside the UI API, authenticated per transport by AS2
** identity, S/MIME signature and certificates (never a user session):
** POST /qqq/hooks/edi/as2/{transport} receives messages and answers with the
** MDN, and POST /qqq/hooks/edi/as2/{transport}/mdn receives asynchronous MDNs.
*******************************************************************************/
public class As2RouteProvider implements QJavalinRouteProviderInterface
{
private static final QLogger LOG = QLogger.getLogger(As2RouteProvider.class);
public static final long DEFAULT_MAX_BODY_BYTES = 50L * 1024 * 1024;
public static final long ABSOLUTE_MAX_BODY_BYTES = 200L * 1024 * 1024;
private QInstance instance;
/*******************************************************************************
** Host instance supplied by Javalin.
*******************************************************************************/
public void setQInstance(QInstance instance)
{
this.instance = instance;
}
/*******************************************************************************
** Register the non-UI hooks.
*******************************************************************************/
public void acceptJavalinConfig(JavalinConfig config)
{
config.routes.post("/qqq/hooks/edi/as2/{transport}", context -> handle(context, false));
config.routes.post("/qqq/hooks/edi/as2/{transport}/mdn", context -> handle(context, true));
}
/*******************************************************************************
** Find the active AS2 transport, read the bounded body and delegate.
*******************************************************************************/
private void handle(Context context, boolean mdn)
{
var previous = QContext.capture();
try
{
QContext.init(instance, null);
As2Receiver.Result result = SystemWrites.call(() -> process(context, mdn));
context.status(result.status());
result.headers().forEach(context::header);
context.result(result.body());
}
catch(Exception e)
{
LOG.warn("AS2 hook failed: " + e.getClass().getSimpleName());
context.status(503);
context.result("The AS2 request could not be processed".getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
finally
{
QContext.init(previous);
}
}
/*******************************************************************************
** The hook body, inside the system context.
*******************************************************************************/
private As2Receiver.Result process(Context context, boolean mdn) throws Exception
{
List<QRecord> rows = EdiRecords.query("edi_transport", EdiRecords.equal("name", context.pathParam("transport")).withCriteria("type", QCriteriaOperator.EQUALS, "AS2")
.withCriteria("isActive", QCriteriaOperator.EQUALS, true));
if(rows.size() != 1 || (mdn ? "INBOUND" : "OUTBOUND").equals(rows.getFirst().getValueString("direction")))
{
return (As2Receiver.Result.text(404, "Unknown AS2 transport"));
}
EdiTransport transport = rows.getFirst().toEntity(EdiTransport.class);
long limit = mdn ? As2Http.MAX_RESPONSE_BYTES : maxBodyBytes(transport);
if(context.req().getContentLengthLong() > limit)
{
return (As2Receiver.Result.text(413, "The message is too large"));
}
byte[] body = context.req().getInputStream().readNBytes((int) limit + 1);
if(body.length > limit)
{
return (As2Receiver.Result.text(413, "The message is too large"));
}
Map<String, String> headers = new TreeMap<>();
context.headerMap().forEach((name, value) -> headers.put(name.toLowerCase(Locale.ROOT), value));
return (mdn ? new As2MdnReceiver().receive(transport, headers, body) : new As2Receiver().receive(transport, headers, body, context.ip()));
}
/*******************************************************************************
** The largest interchange any agreement on this transport allows, or 50 MB.
*******************************************************************************/
private static long maxBodyBytes(EdiTransport transport) throws Exception
{
long limit = 0;
for(QRecord agreement : EdiRecords.query("edi_agreement", EdiRecords.equal("inboundTransportId", transport.getId())))
{
limit = Math.max(limit, agreement.getValueLong("maxInterchangeBytes") == null ? 0 : agreement.getValueLong("maxInterchangeBytes"));
}
return (limit <= 0 ? DEFAULT_MAX_BODY_BYTES : Math.min(limit + limit / 2 + 65536, ABSOLUTE_MAX_BODY_BYTES));
}
}
@@ -0,0 +1,86 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.ByteArrayInputStream;
import java.time.Instant;
import com.helger.as2lib.crypto.MIC;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.storage.PayloadKind;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Persistence helpers for AS2 receipts: the raw MDN goes to the encrypted payload
** store and a read-only edi_as2_mdn row describes it. Callers run inside the
** system context.
*******************************************************************************/
public final class As2Store
{
public static final String TABLE = "edi_as2_mdn";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private As2Store()
{
}
/*******************************************************************************
** Store the raw MDN entity (headers and body) encrypted.
*******************************************************************************/
public static PayloadRef storeRaw(byte[] entity) throws Exception
{
return (EdiRuntime.current().payloads().put(PayloadKind.MDN, new ByteArrayInputStream(entity)));
}
/*******************************************************************************
** The base64 value of a MIC without its algorithm.
*******************************************************************************/
public static String micValue(MIC mic)
{
return (mic == null ? null : java.util.Base64.getEncoder().encodeToString(mic.micBytes()));
}
/*******************************************************************************
** Insert a receipt row and return it.
*******************************************************************************/
public static QRecord insert(QRecord row) throws QException
{
if(row.getValue("receivedAt") == null)
{
row.setValue("receivedAt", Instant.now(EdiRuntime.current().config().getClock()));
}
return (SystemWrites.insert(TABLE, row));
}
}
@@ -0,0 +1,584 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.helger.as2lib.crypto.ECryptoAlgorithmCrypt;
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
import com.helger.as2lib.crypto.MIC;
import com.helger.as2lib.disposition.DispositionOptions;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
import com.kingsrook.qbits.edi.flow.EdiJournal;
import com.kingsrook.qbits.edi.model.EdiOutboundMessage;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qbits.edi.transport.EdiSendResult;
import com.kingsrook.qbits.edi.transport.EdiTestResult;
import com.kingsrook.qbits.edi.transport.EdiTransportPlugin;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** AS2 (RFC 4130) transport. Sending builds the message (compress, sign, then
** encrypt), POSTs it, and for a synchronous receipt verifies the MDN signature,
** the received-content MIC and the disposition. An asynchronous receipt leaves
** the message SENT until the MDN arrives at the receipt hook. No local files are
** used; keys come from environment variables through the secrets resolver.
*******************************************************************************/
public class As2Transport implements EdiTransportPlugin
{
public static final String TYPE = "AS2";
private static final String ID_PATTERN = "[A-Za-z0-9 ._@:/-]{1,128}";
private static final Duration TIMEOUT = Duration.ofSeconds(60);
/*******************************************************************************
** The prepared wire message and the MIC computed for it.
*******************************************************************************/
private record Prepared(As2Crypto.Wire wire, MIC mic)
{
}
/*******************************************************************************
** Registry key.
*******************************************************************************/
@Override
public String getType()
{
return (TYPE);
}
/*******************************************************************************
** Check identifiers, URL, algorithms, receipt mode and certificate ownership
** without resolving any secret. SHA1 and 3DES are accepted only when set
** explicitly; they set the weakCrypto flag on the record.
*******************************************************************************/
@Override
public void validate(EdiTransport transport, List<String> errors)
{
boolean outbound = !"INBOUND".equals(transport.getDirection());
if(transport.getAs2LocalId() == null || !transport.getAs2LocalId().matches(ID_PATTERN))
{
errors.add("as2LocalId is required (letters, digits, spaces and . _ @ : / - only)");
}
if(transport.getAs2PartnerId() == null || !transport.getAs2PartnerId().matches(ID_PATTERN))
{
errors.add("as2PartnerId is required (letters, digits, spaces and . _ @ : / - only)");
}
if(outbound && (transport.getAs2Url() == null || !As2Http.isAllowedUrl(transport.getAs2Url())))
{
errors.add("as2Url must use TLS (HTTP is allowed on loopback only) and carry no credentials");
}
try
{
As2Crypto.signAlgorithm(transport.getSignAlgorithm());
}
catch(IllegalArgumentException e)
{
errors.add("Unsupported signAlgorithm");
}
try
{
As2Crypto.encryptAlgorithm(transport.getEncryptAlgorithm());
}
catch(IllegalArgumentException e)
{
errors.add("Unsupported encryptAlgorithm");
}
if(transport.getMdnMode() != null && !List.of("SYNC", "ASYNC", "NONE").contains(transport.getMdnMode()))
{
errors.add("Unsupported mdnMode");
}
if(Boolean.TRUE.equals(transport.getCompress()) && transport.getSignAlgorithm() == null && transport.getEncryptAlgorithm() == null)
{
errors.add("compress requires signing or encryption");
}
if(transport.getPartnerId() == null && !Boolean.TRUE.equals(transport.getIsVan()))
{
errors.add("partnerId is required unless the transport is a VAN (isVan)");
}
if((transport.getHttpAuthUserEnvVar() == null) != (transport.getHttpAuthPasswordEnvVar() == null))
{
errors.add("httpAuthUserEnvVar and httpAuthPasswordEnvVar must be set together");
}
if(outbound && "ASYNC".equals(transport.getMdnMode()) && baseUrl() == null)
{
errors.add("ASYNC receipts need the qbit publicBaseUrl configuration");
}
try
{
validateCertificates(transport, errors);
}
catch(Exception e)
{
errors.add("Certificates could not be checked");
}
}
/*******************************************************************************
** Certificate references must exist and have the right owner: ours (no
** partner, with a key variable) as the local certificate; the partner's own, or
** for a VAN a certificate that is not ours, as the partner certificate; a TLS
** certificate for pinning. Retired certificates are refused.
*******************************************************************************/
private static void validateCertificates(EdiTransport transport, List<String> errors) throws Exception
{
boolean secured = transport.getSignAlgorithm() != null || transport.getEncryptAlgorithm() != null || Boolean.TRUE.equals(transport.getMdnSigned());
QRecord local = certificate(transport.getLocalCertificateId());
QRecord partner = certificate(transport.getPartnerCertificateId());
QRecord tls = certificate(transport.getPartnerTlsCertificateId());
if(transport.getLocalCertificateId() == null ? secured : local == null)
{
errors.add(transport.getLocalCertificateId() == null ? "localCertificateId is required when signing or encrypting" : "localCertificateId does not exist");
}
else if(local != null && (local.getValue("partnerId") != null || local.getValueString("privateKeyEnvVar") == null || "RETIRED".equals(local.getValueString("status"))))
{
errors.add("localCertificateId must be one of our own certificates (no partner, with a private key variable) that is not retired");
}
if(transport.getPartnerCertificateId() == null ? secured : partner == null)
{
errors.add(transport.getPartnerCertificateId() == null ? "partnerCertificateId is required when signing or encrypting" : "partnerCertificateId does not exist");
}
else if(partner != null)
{
boolean ownedByPartner = transport.getPartnerId() == null || transport.getPartnerId().equals(partner.getValueInteger("partnerId"));
if(!ownedByPartner || partner.getValueString("privateKeyEnvVar") != null || "RETIRED".equals(partner.getValueString("status")))
{
errors.add("partnerCertificateId must be a certificate of the partner (or, for a VAN, of the VAN) that is not retired and holds no private key");
}
}
if(transport.getPartnerTlsCertificateId() != null && (tls == null || !"TLS".equals(tls.getValueString("usage"))))
{
errors.add("partnerTlsCertificateId must be a certificate with usage TLS");
}
}
/*******************************************************************************
** Certificate row by id, or null.
*******************************************************************************/
private static QRecord certificate(Integer id) throws Exception
{
if(id == null)
{
return (null);
}
return (SystemWrites.call(() -> EdiRecords.query(CertificateService.TABLE, EdiRecords.equal("id", id)).stream().findFirst().orElse(null)));
}
/*******************************************************************************
** The deployment public base URL without a trailing slash, or null.
*******************************************************************************/
static String baseUrl()
{
try
{
String url = EdiRuntime.current().config().getPublicBaseUrl();
return (url == null || url.isBlank() ? null : url.replaceAll("/+$", ""));
}
catch(Exception e)
{
return (null);
}
}
/*******************************************************************************
** Build, sign, encrypt and POST the message; settle it from the receipt.
*******************************************************************************/
@Override
public EdiSendResult send(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport) throws Exception
{
return (SystemWrites.call(() -> deliver(message, payload, transport)));
}
/*******************************************************************************
** The send itself, inside the system context. Configuration problems (a
** missing variable, an expired or missing certificate) are RETRY results that
** name only the problem, so the retry policy and alerts apply once fixed.
*******************************************************************************/
private EdiSendResult deliver(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport) throws Exception
{
byte[] edi;
try(InputStream stream = EdiRuntime.current().payloads().open(payload))
{
edi = stream.readAllBytes();
}
String messageId = messageId(message.getId(), transport, edi);
CertificateService certificates = new CertificateService();
String mode = mode(transport);
As2Http.Response response;
Prepared prepared;
try
{
prepared = prepare(message, transport, edi, messageId, certificates);
HttpClient client = As2Http.client(certificates.tlsPin(transport));
response = As2Http.post(client, URI.create(transport.getAs2Url()), headers(transport, messageId, prepared, mode), prepared.wire().body(), TIMEOUT);
}
catch(As2Exception e)
{
return (new EdiSendResult("RETRY", null, e.getMessage()));
}
catch(java.io.IOException e)
{
return (new EdiSendResult("RETRY", null, "The AS2 endpoint could not be reached"));
}
if(response.status() >= 500 || response.status() == 429)
{
return (new EdiSendResult("RETRY", null, "HTTP " + response.status()));
}
if(response.status() < 200 || response.status() >= 300)
{
return (new EdiSendResult("FAILED", null, "HTTP " + response.status()));
}
SystemWrites.update("edi_outbound_message", new QRecord().withValue("id", message.getId()).withValue("transportMic", prepared.mic().getAsAS2String()));
if(!"SYNC".equals(mode))
{
return (new EdiSendResult("SENT", messageId, "ASYNC".equals(mode) ? "Awaiting the asynchronous MDN" : "No receipt requested"));
}
return (settleSync(message, transport, messageId, prepared, response, certificates));
}
/*******************************************************************************
** Compress, sign and encrypt as configured, computing the MIC over the content
** that is signed.
*******************************************************************************/
private Prepared prepare(EdiOutboundMessage message, EdiTransport transport, byte[] edi, String messageId, CertificateService certificates) throws Exception
{
ECryptoAlgorithmSign signAlgorithm = As2Crypto.signAlgorithm(transport.getSignAlgorithm());
ECryptoAlgorithmCrypt encryptAlgorithm = As2Crypto.encryptAlgorithm(transport.getEncryptAlgorithm());
LocalKey key = signAlgorithm == null ? null : certificates.activeLocal(transport);
X509Certificate partner = encryptAlgorithm == null ? null : certificates.activePartnerCertificate(transport);
checkValid("Local certificate", key == null ? null : key.certificate());
checkValid("Partner certificate", partner);
String contentType = edi.length >= 3 && edi[0] == 'U' && edi[1] == 'N' ? "application/edifact" : "application/edi-x12";
var part = As2Crypto.payloadPart(edi, contentType, "edi-" + message.getId() + ".edi");
if(Boolean.TRUE.equals(transport.getCompress()))
{
part = As2Crypto.compress(part);
}
ECryptoAlgorithmSign micAlgorithm = signAlgorithm == null ? ECryptoAlgorithmSign.DIGEST_SHA_256 : signAlgorithm;
byte[] clear;
MIC mic;
if(signAlgorithm == null)
{
clear = As2Crypto.toBytes(part);
mic = As2Crypto.mic(clear, micAlgorithm);
}
else
{
clear = As2Crypto.toBytes(As2Crypto.sign(part, key, signAlgorithm));
As2Crypto.Wire signed = As2Crypto.splitRaw(clear);
mic = As2Crypto.mic(As2Crypto.firstPart(signed.contentType(), signed.body()), micAlgorithm);
}
return (new Prepared(encryptAlgorithm == null ? As2Crypto.splitRaw(clear) : As2Crypto.encrypt(clear, partner, encryptAlgorithm), mic));
}
/*******************************************************************************
** Certificates outside their validity period are not used.
*******************************************************************************/
private static void checkValid(String what, X509Certificate certificate) throws As2Exception
{
if(certificate != null)
{
try
{
certificate.checkValidity();
}
catch(Exception e)
{
throw (new As2Exception(what + " " + certificate.getSerialNumber().toString(16) + " is expired or not yet valid"));
}
}
}
/*******************************************************************************
** The AS2 request headers.
*******************************************************************************/
private Map<String, String> headers(EdiTransport transport, String messageId, Prepared prepared, String mode) throws Exception
{
Map<String, String> headers = new LinkedHashMap<>();
headers.put("AS2-Version", "1.2");
headers.put("AS2-From", asId(transport.getAs2LocalId()));
headers.put("AS2-To", asId(transport.getAs2PartnerId()));
headers.put("Message-ID", "<" + messageId + ">");
headers.put("Subject", subject(transport));
headers.put("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(EdiRuntime.current().config().getClock().withZone(ZoneOffset.UTC))));
headers.put("MIME-Version", "1.0");
headers.put("User-Agent", "qbit-edi");
headers.put("Content-Type", prepared.wire().contentType());
if(prepared.wire().transferEncoding() != null)
{
headers.put("Content-Transfer-Encoding", prepared.wire().transferEncoding());
}
if(prepared.wire().contentDisposition() != null)
{
headers.put("Content-Disposition", prepared.wire().contentDisposition());
}
if(!"NONE".equals(mode))
{
headers.put("Disposition-Notification-To", transport.getAs2LocalId());
if(Boolean.TRUE.equals(transport.getMdnSigned()))
{
ECryptoAlgorithmSign alg = As2Crypto.signAlgorithm(transport.getSignAlgorithm()) == null ? ECryptoAlgorithmSign.DIGEST_SHA_256 : As2Crypto.signAlgorithm(transport.getSignAlgorithm());
headers.put("Disposition-Notification-Options", new DispositionOptions().setProtocolImportance(DispositionOptions.IMPORTANCE_REQUIRED)
.setProtocol(DispositionOptions.PROTOCOL_PKCS7_SIGNATURE).setMICAlgImportance(DispositionOptions.IMPORTANCE_REQUIRED).setMICAlg(alg).getAsString());
}
}
if("ASYNC".equals(mode))
{
headers.put("Receipt-Delivery-Option", baseUrl() + "/qqq/hooks/edi/as2/" + transport.getName() + "/mdn");
}
if(transport.getHttpAuthUserEnvVar() != null)
{
headers.put("Authorization", As2Http.basic(secret(transport.getHttpAuthUserEnvVar()), secret(transport.getHttpAuthPasswordEnvVar())));
}
return (headers);
}
/*******************************************************************************
** Evaluate a synchronous receipt: signature, disposition and MIC.
*******************************************************************************/
private EdiSendResult settleSync(EdiOutboundMessage message, EdiTransport transport, String messageId, Prepared prepared, As2Http.Response response, CertificateService certificates) throws Exception
{
String contentType = response.headers().get("content-type");
if(response.body().length == 0 || contentType == null)
{
return (new EdiSendResult("RETRY", messageId, "The partner returned no MDN"));
}
As2Mdn.Parsed mdn;
try
{
mdn = As2Mdn.parse(contentType, response.headers().get("content-transfer-encoding"), response.body(), certificates.partnerVerificationCertificates(transport),
Date.from(EdiRuntime.current().config().getClock().instant()));
}
catch(As2Exception e)
{
return (new EdiSendResult("RETRY", messageId, e.getMessage()));
}
String failure = judge(transport, mdn, prepared.mic());
PayloadRef raw = As2Store.storeRaw(As2Crypto.entity(contentType, response.headers().get("content-transfer-encoding"), null, response.body()));
As2Store.insert(new QRecord().withValue("direction", "INBOUND").withValue("messageId", As2Mdn.sanitize(As2Mdn.normalizeId(response.headers().get("message-id"))))
.withValue("originalMessageId", messageId).withValue("disposition", mdn.disposition()).withValue("micAlgorithm", mdn.mic() == null ? null : mdn.mic().getDigestAlgorithm().getID())
.withValue("micValue", As2Store.micValue(mdn.mic())).withValue("signed", mdn.signed()).withValue("signatureValid", mdn.signatureValid()).withValue("payloadRef", raw.value())
.withValue("transportId", transport.getId()).withValue("partnerId", message.getPartnerId()).withValue("outboundMessageId", message.getId()).withValue("deliveryStatus", "NONE"));
EdiJournal.event(null, message.getInterchangeId(), null, "MDN");
if(failure != null)
{
return (new EdiSendResult("FAILED", messageId, failure));
}
QAlerts.resolve("edi.mdn.overdue", "edi_outbound_message:" + message.getId(), "MDN received");
return (new EdiSendResult("DELIVERED", messageId, "MDN processed"));
}
/*******************************************************************************
** The reason a receipt does not confirm delivery, or null when it does.
*******************************************************************************/
static String judge(EdiTransport transport, As2Mdn.Parsed mdn, MIC ours)
{
if(Boolean.TRUE.equals(mdn.signed()) && !Boolean.TRUE.equals(mdn.signatureValid()))
{
return ("The MDN signature could not be verified");
}
if(Boolean.TRUE.equals(transport.getMdnSigned()) && !Boolean.TRUE.equals(mdn.signed()))
{
return ("The MDN is not signed");
}
if(!mdn.processed())
{
return ("Partner disposition: " + mdn.disposition());
}
if(ours != null && (mdn.mic() == null ? Boolean.TRUE.equals(transport.getMdnSigned()) || mdn.micText() != null : !mdn.mic().equals(ours)))
{
return ("The MDN Received-Content-MIC does not match the message sent");
}
return (null);
}
/*******************************************************************************
** Probe configuration without sending any document: resolve keys and
** certificates, then OPTIONS the endpoint.
*******************************************************************************/
@Override
public EdiTestResult test(EdiTransport transport)
{
try
{
return (SystemWrites.call(() -> probe(transport)));
}
catch(Exception e)
{
return (new EdiTestResult(false, "AS2 test could not run; check the database and configuration"));
}
}
/*******************************************************************************
** Resolve keys and certificates and reach the endpoint.
*******************************************************************************/
private EdiTestResult probe(EdiTransport transport) throws Exception
{
CertificateService certificates = new CertificateService();
List<String> notes = new java.util.ArrayList<>();
try
{
boolean secured = transport.getSignAlgorithm() != null || transport.getEncryptAlgorithm() != null || Boolean.TRUE.equals(transport.getMdnSigned());
if(secured || transport.getLocalCertificateId() != null)
{
LocalKey key = certificates.activeLocal(transport);
notes.add("Local key " + key.row().getValueString("alias") + " resolved (valid until " + key.certificate().getNotAfter().toInstant() + ")");
}
if(secured || transport.getPartnerCertificateId() != null)
{
certificates.activePartnerCertificate(transport);
notes.add(certificates.partnerVerificationCertificates(transport).size() + " partner certificate(s) accepted for verification");
}
HttpClient client = As2Http.client(certificates.tlsPin(transport));
if(!"INBOUND".equals(transport.getDirection()) && transport.getAs2Url() != null)
{
int status = As2Http.options(client, URI.create(transport.getAs2Url()), Map.of(), Duration.ofSeconds(15)).status();
notes.add("Endpoint responded with HTTP " + status);
if(status >= 500)
{
return (new EdiTestResult(false, String.join("; ", notes)));
}
}
}
catch(As2Exception e)
{
notes.add(e.getMessage());
return (new EdiTestResult(false, String.join("; ", notes)));
}
catch(java.io.IOException e)
{
notes.add("The AS2 endpoint could not be reached");
return (new EdiTestResult(false, String.join("; ", notes)));
}
if(As2Crypto.isWeak(transport.getSignAlgorithm(), transport.getEncryptAlgorithm()))
{
notes.add("Warning: weak crypto (SHA1 or 3DES) is configured");
}
return (new EdiTestResult(true, String.join("; ", notes)));
}
/*******************************************************************************
** Receipt mode, defaulting to SYNC.
*******************************************************************************/
static String mode(EdiTransport transport)
{
return (transport.getMdnMode() == null ? "SYNC" : transport.getMdnMode());
}
/*******************************************************************************
** A stable Message-ID: retries of the same outbound message reuse it, so the
** partner can recognize them.
*******************************************************************************/
static String messageId(Integer messageId, EdiTransport transport, byte[] edi) throws Exception
{
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(edi)).substring(0, 12);
return ("edi-" + messageId + "-" + digest + "@" + transport.getAs2LocalId().replaceAll("[^A-Za-z0-9.-]", "-"));
}
/*******************************************************************************
** An AS2 identifier as a header value; identifiers with spaces are quoted.
*******************************************************************************/
static String asId(String id)
{
return (id.contains(" ") ? "\"" + id + "\"" : id);
}
/*******************************************************************************
** Subject header without line breaks.
*******************************************************************************/
private static String subject(EdiTransport transport)
{
String subject = transport.getAs2Subject() == null || transport.getAs2Subject().isBlank() ? "EDI interchange" : transport.getAs2Subject();
return (subject.replaceAll("[\\r\\n]+", " "));
}
/*******************************************************************************
** Resolve a secret by environment variable name; failure names only the name.
*******************************************************************************/
private static String secret(String variable) throws As2Exception
{
String value = EdiRuntime.current().config().getSecretsResolver().resolve(variable);
if(value == null)
{
throw (new As2Exception("environment variable " + variable + " is not set"));
}
return (value);
}
}
@@ -0,0 +1,102 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** Pre-insert and pre-update customizer for edi_certificate. It parses the pasted
** public certificate and fills subject, issuer, serial number, fingerprint and
** validity dates, and refuses a private key pasted into certificatePem (the
** private key belongs in the environment variable named by privateKeyEnvVar).
*******************************************************************************/
public class CertificateCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Parse the certificate on save; reject private keys and key variables on
** partner certificates without echoing any value.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
{
for(QRecord record : records)
{
QRecord merged = new QRecord();
oldRecords.orElse(List.of()).stream().filter(old -> Objects.equals(old.getValue("id"), record.getValue("id")))
.findFirst().ifPresent(old -> merged.getValues().putAll(old.getValues()));
merged.getValues().putAll(record.getValues());
boolean insert = oldRecords.isEmpty() || record.getValue("id") == null;
if(insert || record.getValues().containsKey("certificatePem"))
{
try
{
X509Certificate certificate = CertificateService.parse(merged.getValueString("certificatePem"));
record.setValue("subject", abbreviate(certificate.getSubjectX500Principal().getName()));
record.setValue("issuer", abbreviate(certificate.getIssuerX500Principal().getName()));
record.setValue("serialNumber", certificate.getSerialNumber().toString(16).toUpperCase());
record.setValue("fingerprintSha256", CertificateService.fingerprint(certificate));
record.setValue("notBefore", certificate.getNotBefore().toInstant());
record.setValue("notAfter", certificate.getNotAfter().toInstant());
merged.setValue("notAfter", certificate.getNotAfter().toInstant());
}
catch(As2Exception e)
{
record.addError(new BadInputStatusMessage(e.getMessage()));
}
}
if(insert && merged.getValueString("status") == null)
{
record.setValue("status", "ACTIVE");
}
if(merged.getValueInteger("partnerId") != null && merged.getValueString("privateKeyEnvVar") != null && !merged.getValueString("privateKeyEnvVar").isBlank())
{
record.addError(new BadInputStatusMessage("privateKeyEnvVar is only for our own certificates; leave it blank on a partner certificate"));
}
Instant notAfter = merged.getValueInstant("notAfter");
if(notAfter != null && merged.getValueInstant("notBefore") != null && !notAfter.isAfter(merged.getValueInstant("notBefore")))
{
record.addError(new BadInputStatusMessage("The certificate validity period is empty"));
}
}
return (records);
}
/*******************************************************************************
** Keep parsed names within the column length.
*******************************************************************************/
private static String abbreviate(String value)
{
return (value.length() <= 255 ? value : value.substring(0, 255));
}
}
@@ -0,0 +1,474 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.ByteArrayInputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder;
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
/*******************************************************************************
** Resolves the certificates and keys an AS2 transport uses. Public certificates
** come from edi_certificate rows; our private keys come only from environment
** variables (a PEM PKCS#8 key or a base64 PKCS#12 store) through the replaceable
** secrets resolver, are matched to the certificate, and are never cached,
** logged, stored or returned. Callers run inside the system context.
*******************************************************************************/
public final class CertificateService
{
private static final QLogger LOG = QLogger.getLogger(CertificateService.class);
public static final String TABLE = "edi_certificate";
/*******************************************************************************
** Our certificate with its matched private key; the key is excluded from any
** string form of this object.
*******************************************************************************/
public record LocalKey(QRecord row, X509Certificate certificate, PrivateKey key)
{
/*******************************************************************************
** Never render key material.
*******************************************************************************/
@Override
public String toString()
{
return ("LocalKey[" + row.getValueString("alias") + "]");
}
}
/*******************************************************************************
** Local keys usable for decryption, plus the problems (naming environment
** variables only) for keys that could not be loaded.
*******************************************************************************/
public record KeySet(List<LocalKey> keys, List<String> problems)
{
}
/*******************************************************************************
** True when the text contains any private key block.
*******************************************************************************/
public static boolean containsPrivateKey(String text)
{
return (text != null && text.toUpperCase().contains("PRIVATE KEY"));
}
/*******************************************************************************
** Parse the first X.509 certificate of a PEM text; private keys are refused
** without echoing any content.
*******************************************************************************/
public static X509Certificate parse(String pem) throws As2Exception
{
if(pem == null || pem.isBlank())
{
throw (new As2Exception("certificatePem is required"));
}
if(containsPrivateKey(pem))
{
throw (new As2Exception("certificatePem must hold only the public certificate; keep the private key in the environment variable named by privateKeyEnvVar"));
}
if(!pem.contains("-----BEGIN CERTIFICATE-----"))
{
throw (new As2Exception("certificatePem must be a PEM encoded X.509 certificate"));
}
Collection<?> certificates;
try
{
certificates = CertificateFactory.getInstance("X.509").generateCertificates(new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)));
}
catch(Exception e)
{
throw (new As2Exception("certificatePem could not be parsed as an X.509 certificate"));
}
if(certificates.isEmpty() || !(certificates.iterator().next() instanceof X509Certificate certificate))
{
throw (new As2Exception("certificatePem does not contain an X.509 certificate"));
}
return (certificate);
}
/*******************************************************************************
** Upper-case colon separated SHA-256 fingerprint of the certificate.
*******************************************************************************/
public static String fingerprint(X509Certificate certificate) throws As2Exception
{
try
{
return (HexFormat.ofDelimiter(":").withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded())));
}
catch(Exception e)
{
throw (new As2Exception("certificate fingerprint could not be computed"));
}
}
/*******************************************************************************
** Resolve the key used to sign messages and receipts for the transport: the
** referenced certificate when it is ACTIVE, otherwise the ACTIVE certificate
** of ours with the same usage (so a promoted rollover takes effect at once).
*******************************************************************************/
public LocalKey activeLocal(EdiTransport transport) throws As2Exception, QException
{
QRecord referenced = byId(transport.getLocalCertificateId());
if(referenced == null)
{
throw (new As2Exception("The local certificate of transport " + transport.getName() + " is not configured"));
}
QRecord active = "ACTIVE".equals(referenced.getValueString("status")) ? referenced : findActive(null, referenced.getValueString("usage"), true);
if(active == null)
{
throw (new As2Exception("No ACTIVE local certificate is available for transport " + transport.getName()));
}
return (load(active));
}
/*******************************************************************************
** Every local key inbound decryption accepts: our ACTIVE and NEXT certificates
** that can decrypt. A key that cannot be loaded is skipped and reported by
** environment variable name only.
*******************************************************************************/
public KeySet localDecryptionKeys() throws QException
{
List<LocalKey> keys = new ArrayList<>();
List<String> problems = new ArrayList<>();
QQueryFilter filter = new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "ACTIVE", "NEXT")
.withCriteria("partnerId", QCriteriaOperator.IS_BLANK).withCriteria("privateKeyEnvVar", QCriteriaOperator.IS_NOT_BLANK);
for(QRecord row : EdiRecords.query(TABLE, filter))
{
if(!List.of("ENCRYPTION", "SIGNING_AND_ENCRYPTION").contains(row.getValueString("usage")))
{
continue;
}
try
{
keys.add(load(row));
}
catch(As2Exception e)
{
problems.add(e.getMessage());
LOG.warn("Local AS2 key skipped: " + e.getMessage());
}
}
return (new KeySet(keys, problems));
}
/*******************************************************************************
** The partner certificate outbound messages are encrypted to: the referenced
** certificate when ACTIVE, otherwise the partner ACTIVE certificate of the
** same usage.
*******************************************************************************/
public X509Certificate activePartnerCertificate(EdiTransport transport) throws As2Exception, QException
{
QRecord referenced = byId(transport.getPartnerCertificateId());
if(referenced == null)
{
throw (new As2Exception("The partner certificate of transport " + transport.getName() + " is not configured"));
}
QRecord active = "ACTIVE".equals(referenced.getValueString("status")) ? referenced : findActive(referenced.getValueInteger("partnerId"), referenced.getValueString("usage"), false);
if(active == null)
{
throw (new As2Exception("No ACTIVE partner certificate is available for transport " + transport.getName()));
}
return (parse(active.getValueString("certificatePem")));
}
/*******************************************************************************
** Partner certificates a signature may verify against: the referenced one and
** the same partner other ACTIVE or NEXT signing certificates. A partner
** certificate with no partner (a shared VAN) has no rollover candidates.
*******************************************************************************/
public List<X509Certificate> partnerVerificationCertificates(EdiTransport transport) throws As2Exception, QException
{
QRecord referenced = byId(transport.getPartnerCertificateId());
if(referenced == null)
{
throw (new As2Exception("The partner certificate of transport " + transport.getName() + " is not configured"));
}
List<QRecord> rows = new ArrayList<>();
if(List.of("ACTIVE", "NEXT").contains(referenced.getValueString("status")))
{
rows.add(referenced);
}
if(referenced.getValueInteger("partnerId") != null)
{
QQueryFilter filter = EdiRecords.equal("partnerId", referenced.getValueInteger("partnerId")).withCriteria("status", QCriteriaOperator.IN, "ACTIVE", "NEXT")
.withCriteria("usage", QCriteriaOperator.IN, "SIGNING", "SIGNING_AND_ENCRYPTION").withCriteria("privateKeyEnvVar", QCriteriaOperator.IS_BLANK);
for(QRecord row : EdiRecords.query(TABLE, filter))
{
if(rows.stream().noneMatch(existing -> Objects.equals(existing.getValue("id"), row.getValue("id"))))
{
rows.add(row);
}
}
}
List<X509Certificate> certificates = new ArrayList<>();
for(QRecord row : rows)
{
certificates.add(parse(row.getValueString("certificatePem")));
}
return (certificates);
}
/*******************************************************************************
** The pinned TLS certificate of the partner endpoint, or null when no pin is set.
*******************************************************************************/
public X509Certificate tlsPin(EdiTransport transport) throws As2Exception, QException
{
QRecord row = byId(transport.getPartnerTlsCertificateId());
if(transport.getPartnerTlsCertificateId() != null && row == null)
{
throw (new As2Exception("The partner TLS certificate of transport " + transport.getName() + " does not exist"));
}
return (row == null ? null : parse(row.getValueString("certificatePem")));
}
/*******************************************************************************
** Promote a NEXT certificate to ACTIVE and retire the previous ACTIVE one of
** the same partner and usage. Returns false when the certificate is not NEXT.
*******************************************************************************/
public boolean promote(QRecord row) throws QException
{
if(!"NEXT".equals(row.getValueString("status")))
{
return (false);
}
boolean local = row.getValueInteger("partnerId") == null && row.getValueString("privateKeyEnvVar") != null;
QRecord previous = findActive(row.getValueInteger("partnerId"), row.getValueString("usage"), local);
while(previous != null)
{
SystemWrites.update(TABLE, new QRecord().withValue("id", previous.getValue("id")).withValue("status", "RETIRED"));
previous = findActive(row.getValueInteger("partnerId"), row.getValueString("usage"), local);
}
SystemWrites.update(TABLE, new QRecord().withValue("id", row.getValue("id")).withValue("status", "ACTIVE"));
return (true);
}
/*******************************************************************************
** Certificate row by id, or null.
*******************************************************************************/
private QRecord byId(Integer id) throws QException
{
if(id == null)
{
return (null);
}
List<QRecord> rows = EdiRecords.query(TABLE, EdiRecords.equal("id", id));
return (rows.isEmpty() ? null : rows.getFirst());
}
/*******************************************************************************
** The ACTIVE certificate of a partner (null partner means ours or a shared
** one) and usage, local ones being those with a private key variable.
*******************************************************************************/
private QRecord findActive(Integer partnerId, String usage, boolean local) throws QException
{
QQueryFilter filter = EdiRecords.equal("status", "ACTIVE").withCriteria("partnerId", partnerId == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, partnerId)
.withCriteria("usage", QCriteriaOperator.EQUALS, usage).withCriteria("privateKeyEnvVar", local ? QCriteriaOperator.IS_NOT_BLANK : QCriteriaOperator.IS_BLANK);
List<QRecord> rows = EdiRecords.query(TABLE, filter);
return (rows.stream().min((a, b) -> Integer.compare(a.getValueInteger("id"), b.getValueInteger("id"))).orElse(null));
}
/*******************************************************************************
** Parse the certificate, read its private key from the environment and prove
** the two belong together by signing and verifying a probe.
*******************************************************************************/
private LocalKey load(QRecord row) throws As2Exception
{
X509Certificate certificate = parse(row.getValueString("certificatePem"));
String variable = row.getValueString("privateKeyEnvVar");
if(variable == null || variable.isBlank())
{
throw (new As2Exception("Certificate " + row.getValueString("alias") + " has no privateKeyEnvVar"));
}
PrivateKey key = readKey(variable, row.getValueString("privateKeyPasswordEnvVar"));
if(!matches(key, certificate))
{
throw (new As2Exception("The private key in environment variable " + variable + " does not match certificate " + row.getValueString("alias")));
}
return (new LocalKey(row, certificate, key));
}
/*******************************************************************************
** Read the key from the named environment variable; failures name only the
** variable.
*******************************************************************************/
private static PrivateKey readKey(String variable, String passwordVariable) throws As2Exception
{
var secrets = EdiRuntime.current().config().getSecretsResolver();
String value = secrets.resolve(variable);
if(value == null || value.isBlank())
{
throw (new As2Exception("environment variable " + variable + " is not set"));
}
char[] password = new char[0];
if(passwordVariable != null && !passwordVariable.isBlank())
{
String resolved = secrets.resolve(passwordVariable);
if(resolved == null)
{
throw (new As2Exception("environment variable " + passwordVariable + " is not set"));
}
password = resolved.toCharArray();
}
try
{
return (parseKey(value, password));
}
catch(Exception e)
{
throw (new As2Exception("The private key in environment variable " + variable + " could not be read as a PEM PKCS#8 key or a base64 PKCS#12 store"));
}
}
/*******************************************************************************
** Parse a PEM key (PKCS#8, encrypted PKCS#8 or traditional) or a base64 PKCS#12
** store, entirely in memory.
*******************************************************************************/
static PrivateKey parseKey(String raw, char[] password) throws Exception
{
String text = raw.trim();
if(text.indexOf('\n') < 0 && text.contains("\\n"))
{
text = text.replace("\\n", "\n");
}
if(text.contains("-----BEGIN"))
{
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
try(PEMParser parser = new PEMParser(new StringReader(text)))
{
Object object = parser.readObject();
while(object != null)
{
if(object instanceof PrivateKeyInfo info)
{
return (converter.getPrivateKey(info));
}
if(object instanceof PEMKeyPair pair)
{
return (converter.getKeyPair(pair).getPrivate());
}
if(object instanceof PKCS8EncryptedPrivateKeyInfo encrypted)
{
return (converter.getPrivateKey(encrypted.decryptPrivateKeyInfo(new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password))));
}
object = parser.readObject();
}
}
throw (new IllegalArgumentException("no private key block"));
}
KeyStore store = KeyStore.getInstance("PKCS12");
store.load(new ByteArrayInputStream(Base64.getMimeDecoder().decode(text)), password);
for(String alias : Collections.list(store.aliases()))
{
if(store.isKeyEntry(alias))
{
return ((PrivateKey) store.getKey(alias, password));
}
}
throw (new IllegalArgumentException("no key entry"));
}
/*******************************************************************************
** Sign a probe with the key and verify it with the certificate public key.
*******************************************************************************/
private static boolean matches(PrivateKey key, X509Certificate certificate)
{
try
{
String algorithm = "EC".equals(key.getAlgorithm()) ? "SHA256withECDSA" : "SHA256with" + key.getAlgorithm();
byte[] probe = "qbit-edi-key-match".getBytes(StandardCharsets.UTF_8);
Signature signer = Signature.getInstance(algorithm);
signer.initSign(key);
signer.update(probe);
byte[] signature = signer.sign();
Signature verifier = Signature.getInstance(algorithm);
verifier.initVerify(certificate.getPublicKey());
verifier.update(probe);
return (verifier.verify(signature));
}
catch(Exception e)
{
return (false);
}
}
}
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
@@ -65,7 +66,7 @@ public class AckMonitor
}
count++;
}
for(QRecord certificate : EdiRecords.query("edi_certificate", EdiRecords.equal("status", "ACTIVE")))
for(QRecord certificate : EdiRecords.query("edi_certificate", new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "ACTIVE", "NEXT")))
{
if(certificate.getValueInstant("notAfter") == null)
{
@@ -87,10 +88,51 @@ public class AckMonitor
}
}
}
return (count);
return (count + overdueMdns(now));
}));
}
/*******************************************************************************
** SENT messages on an asynchronous-MDN AS2 transport whose MDN has not arrived
** within the agreement document ackDueMinutes (60 when none is set) raise a
** warning, deduplicated per message and resolved when the MDN arrives.
*******************************************************************************/
private static int overdueMdns(java.time.Instant now) throws QException
{
List<java.io.Serializable> transports = new java.util.ArrayList<>();
for(QRecord transport : EdiRecords.query("edi_transport", EdiRecords.equal("type", "AS2").withCriteria("mdnMode", QCriteriaOperator.EQUALS, "ASYNC")))
{
transports.add(transport.getValue("id"));
}
if(transports.isEmpty())
{
return (0);
}
int count = 0;
for(QRecord message : EdiRecords.query("edi_outbound_message", EdiRecords.equal("status", "SENT").withCriteria("transportId", QCriteriaOperator.IN, transports)
.withCriteria("sentAt", QCriteriaOperator.IS_NOT_BLANK)))
{
int minutes = 60;
Integer smallest = null;
for(QRecord document : message.getValue("agreementId") == null ? List.<QRecord>of()
: EdiRecords.query("edi_agreement_document", EdiRecords.equal("agreementId", message.getValue("agreementId")).withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND")))
{
Integer due = document.getValueInteger("ackDueMinutes");
smallest = due == null ? smallest : smallest == null ? due : Math.min(smallest, due);
}
minutes = smallest == null ? minutes : smallest;
if(message.getValueInstant("sentAt").plus(minutes, ChronoUnit.MINUTES).isAfter(now))
{
continue;
}
QAlerts.raise(new AlertSignal("edi.mdn.overdue").withSeverity(AlertSeverity.WARNING).withDedupKey("edi_outbound_message:" + message.getValue("id"))
.withRecordRef(new com.kingsrook.qqq.backend.core.alerts.AlertRecordRef("edi_outbound_message", message.getValue("id")))
.withTitle("AS2 receipt overdue").withSummary("The asynchronous MDN has not arrived; review the outbound message"));
count++;
}
return (count);
}
/*******************************************************************************
** Each certificate threshold has its own stable deduplication key.
*******************************************************************************/
@@ -101,7 +101,7 @@ public class SendWorker
boolean sent = List.of("SENT", "DELIVERED").contains(result.status());
String status = sent ? result.status() : "FAILED".equals(result.status()) ? "FAILED" : attempts >= maximum ? "DEAD_LETTER" : "RETRY_WAIT";
QRecord update = new QRecord().withValue("id", message.getValue("id")).withValue("status", status).withValue("attempts", attempts)
.withValue("claimedAt", null).withValue("lastError", sent ? null : "Transport delivery failed")
.withValue("claimedAt", null).withValue("lastError", sent ? null : failureText(result))
.withValue("nextAttemptAt", "RETRY_WAIT".equals(status) ? now().plusSeconds(BACKOFF.get(Math.min(attempts - 1, BACKOFF.size() - 1))) : null);
if(sent)
{
@@ -145,6 +145,17 @@ public class SendWorker
}));
}
/*******************************************************************************
** The recorded failure: the transport reason (value-free by contract, for
** example an HTTP status, an AS2 disposition or a missing variable name), or a
** generic text.
*******************************************************************************/
private static String failureText(EdiSendResult result)
{
String reason = result.reason();
return (reason == null || reason.isBlank() ? "Transport delivery failed" : reason.length() <= 255 ? reason : reason.substring(0, 255));
}
/*******************************************************************************
** Clock controlled by the host and deterministic tests.
*******************************************************************************/
@@ -69,7 +69,7 @@ public class EdiMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataP
}
output.add(com.kingsrook.qbits.edi.app.EdiHome.app(getQBitConfig(), tables));
com.kingsrook.qbits.edi.app.EdiHome.widgets().forEach(output::add);
for(String name : java.util.List.of("edi.inbox.dead-letter", "edi.ack.overdue", "edi.ack.rejected", "edi.send.failed", "edi.certificate.expiring", "edi.certificate.expired"))
for(String name : java.util.List.of("edi.inbox.dead-letter", "edi.ack.overdue", "edi.ack.rejected", "edi.send.failed", "edi.certificate.expiring", "edi.certificate.expired", "edi.mdn.overdue"))
{
output.add(new com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData().withName(name).withLabel(name)
.withSourceName("edi").withDefaultSeverity(com.kingsrook.qqq.backend.core.alerts.AlertSeverity.ERROR)
@@ -80,6 +80,7 @@ public class EdiRecordLabels implements TableCustomizerInterface, QSupplementalI
case "edi_exception" -> table.withRecordLabelFormatAndFields("%s: %s", "type", "summary");
case "edi_inbox" -> table.withRecordLabelFormatAndFields("%s %s", "transportType", "externalId");
case "edi_outbound_message" -> table.withRecordLabelFormatAndFields("%s %s", "transportId", "status");
case "edi_as2_mdn" -> table.withRecordLabelFormatAndFields("%s MDN %s", "direction", "originalMessageId");
case "edi_outbound_request" -> table.withRecordLabelFormatAndFields("%s %s %s", "sourceTableName", "sourceRecordId", "status");
case "edi_transaction_error" -> table.withRecordLabelFormatAndFields("%s: %s", "severity", "message");
case "edi_event" -> table.withRecordLabelFormatAndFields("%s %s", "type", "at");
@@ -40,7 +40,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
*******************************************************************************/
public final class EdiTablePresentation
{
private static final Set<String> ACRONYMS = Set.of("ID", "PHI", "PII", "URL", "ISA", "GS", "ST", "AS2", "SFTP", "HTTPS", "HTTP", "MDN", "TLS", "TA1", "EDI", "UPC", "NPI", "QQQ", "JSON", "PEM", "SHA256", "SNIP", "VAN", "AK3", "AK4", "IK3", "IK4");
private static final Set<String> ACRONYMS = Set.of("ID", "PHI", "PII", "URL", "ISA", "GS", "ST", "AS2", "SFTP", "HTTPS", "HTTP", "MDN", "TLS", "TA1", "EDI", "UPC", "NPI", "QQQ", "JSON", "PEM", "SHA256", "SNIP", "VAN", "AK3", "AK4", "IK3", "IK4", "MIC");
@@ -76,6 +76,7 @@ public final class EdiTablePresentation
case "edi_inbox" -> "Inbound Message";
case "edi_outbound_request" -> "Outbound Request";
case "edi_outbound_message" -> "Outbound Message";
case "edi_as2_mdn" -> "AS2 MDN";
case "edi_payload" -> "Payload";
default -> throw new IllegalArgumentException("Missing EDI table label: " + table);
});
@@ -198,6 +199,7 @@ public final class EdiTablePresentation
case "edi_inbox" -> List.of("transportType", "externalId", "status", "partnerId", "transportId", "receivedAt", "attempts");
case "edi_outbound_request" -> List.of("status", "agreementDocumentId", "sourceTableName", "sourceRecordId", "attempts", "nextAttemptAt");
case "edi_outbound_message" -> List.of("transportId", "status", "partnerId", "agreementId", "attempts", "sentAt", "deliveredAt");
case "edi_as2_mdn" -> List.of("direction", "originalMessageId", "disposition", "signed", "signatureValid", "partnerId", "transportId", "receivedAt");
case "edi_payload" -> List.of("kind", "sizeBytes", "sha256");
default -> throw new IllegalArgumentException("Missing EDI field order: " + table);
});
@@ -26,6 +26,7 @@ import java.util.List;
import com.kingsrook.qbits.edi.EdiQBitConfig;
import com.kingsrook.qbits.edi.model.EdiAgreement;
import com.kingsrook.qbits.edi.model.EdiAgreementDocument;
import com.kingsrook.qbits.edi.model.EdiAs2Mdn;
import com.kingsrook.qbits.edi.model.EdiCertificate;
import com.kingsrook.qbits.edi.model.EdiCodeList;
import com.kingsrook.qbits.edi.model.EdiCodeListValue;
@@ -118,6 +119,8 @@ public final class EdiTables
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(TransportValidation.class));
tables.add(transport);
QTableMetaData certificate = base(config, EdiCertificate.TABLE_NAME, EdiCertificate.class, "alias", false);
certificate.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(com.kingsrook.qbits.edi.as2.CertificateCustomizer.class))
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(com.kingsrook.qbits.edi.as2.CertificateCustomizer.class));
tables.add(certificate);
QTableMetaData guide = base(config, EdiGuide.TABLE_NAME, EdiGuide.class, "name", false);
guide.withUniqueKey(new UniqueKey("name", "revision"));
@@ -156,6 +159,7 @@ public final class EdiTables
tables.add(base(config, "edi_map_sample", EdiMapSample.class, "name", false));
tables.add(base(config, "edi_outbound_request", EdiOutboundRequest.class, "id", true).withUniqueKey(new UniqueKey("agreementDocumentId", "eventId")));
tables.add(base(config, "edi_outbound_message", EdiOutboundMessage.class, "id", true));
tables.add(base(config, EdiAs2Mdn.TABLE_NAME, EdiAs2Mdn.class, "originalMessageId", true));
for(QTableMetaData table : tables)
{
List<String> guidePath = switch(table.getName())
@@ -305,6 +309,7 @@ public final class EdiTables
sources.add(enumSource("edi_exception_type", "UNKNOWN_PARTNER", "PARTNER_MISMATCH", "NOT_CONFIGURED", "DUPLICATE", "ENVELOPE", "VALIDATION", "MAPPING", "APPLY", "TRANSPORT", "REJECTED_ACK", "OVERDUE_ACK", "DEAD_LETTER"));
sources.add(enumSource("edi_exception_status", "OPEN", "IN_PROGRESS", "RESOLVED", "IGNORED"));
sources.add(enumSource("edi_inbox_status", "PENDING", "PROCESSING", "PROCESSED", "RETRY_WAIT", "DEAD_LETTER", "DUPLICATE", "IGNORED"));
sources.add(enumSource("edi_as2_mdn_delivery_status", "NONE", "PENDING", "SENT", "FAILED"));
sources.add(enumSource("edi_payload_kind", "INBOUND", "OUTBOUND", "AS2", "MDN", "SAMPLE"));
sources.add(QPossibleValueSource.newForTable("edi_partner"));
sources.add(QPossibleValueSource.newForTable("edi_identity"));
@@ -333,6 +338,7 @@ public final class EdiTables
sources.add(QPossibleValueSource.newForTable("edi_map_sample"));
sources.add(QPossibleValueSource.newForTable("edi_outbound_request"));
sources.add(QPossibleValueSource.newForTable("edi_outbound_message"));
sources.add(QPossibleValueSource.newForTable("edi_as2_mdn"));
return (sources);
}
@@ -403,6 +409,8 @@ public final class EdiTables
joins.add(new QJoinMetaData().withName("edi_agreement_document_agreement_document_id_edi_outbound_request").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_agreement_document").withRightTable("edi_outbound_request").withJoinOn(new JoinOn("id", "agreementDocumentId")));
joins.add(new QJoinMetaData().withName("edi_agreement_agreement_id_edi_outbound_message").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_agreement").withRightTable("edi_outbound_message").withJoinOn(new JoinOn("id", "agreementId")));
joins.add(new QJoinMetaData().withName("edi_outbound_message_outbound_message_id_edi_interchange").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_outbound_message").withRightTable("edi_interchange").withJoinOn(new JoinOn("id", "outboundMessageId")));
joins.add(new QJoinMetaData().withName("edi_outbound_message_outbound_message_id_edi_as2_mdn").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_outbound_message").withRightTable("edi_as2_mdn").withJoinOn(new JoinOn("id", "outboundMessageId")));
joins.add(new QJoinMetaData().withName("edi_inbox_inbox_id_edi_as2_mdn").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_inbox").withRightTable("edi_as2_mdn").withJoinOn(new JoinOn("id", "inboxId")));
joins.add(new QJoinMetaData().withName("edi_inbox_inbox_id_edi_event").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_inbox").withRightTable("edi_event").withJoinOn(new JoinOn("id", "inboxId")));
return (joins);
}
@@ -424,7 +432,7 @@ public final class EdiTables
{
return (switch(table)
{
case "edi_transaction", "edi_interchange", "edi_exception", "edi_inbox", "edi_event", "edi_outbound_request", "edi_outbound_message" ->
case "edi_transaction", "edi_interchange", "edi_exception", "edi_inbox", "edi_event", "edi_outbound_request", "edi_outbound_message", "edi_as2_mdn" ->
List.of(new QFilterOrderBy("createDate", false), new QFilterOrderBy("id", false));
case "edi_guide_segment", "edi_map_rule" -> List.of(new QFilterOrderBy("sequence"), new QFilterOrderBy("id"));
case "edi_guide_element" -> List.of(new QFilterOrderBy("position"), new QFilterOrderBy("componentPosition"), new QFilterOrderBy("id"));
@@ -74,6 +74,7 @@ public class TransportValidation implements TableCustomizerInterface
errors.add("maxAttempts must be positive");
}
errors.forEach(error -> record.addError(new BadInputStatusMessage(error)));
record.setValue("weakCrypto", "AS2".equals(merged.getValueString("type")) && com.kingsrook.qbits.edi.as2.As2Crypto.isWeak(merged.getValueString("signAlgorithm"), merged.getValueString("encryptAlgorithm")));
}
catch(Exception e)
{
@@ -0,0 +1,718 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_as2_mdn record: an AS2 receipt we sent or received. Rows are
** written by the qbit only; the delivery columns queue asynchronous receipts.
*******************************************************************************/
public class EdiAs2Mdn extends QRecordEntity
{
public static final String TABLE_NAME = "edi_as2_mdn";
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "direction", maxLength = 20, possibleValueSourceName = "edi_direction")
private String direction;
@QField(backendName = "message_id", maxLength = 255)
private String messageId;
@QField(backendName = "original_message_id", maxLength = 255)
private String originalMessageId;
@QField(backendName = "disposition", maxLength = 255)
private String disposition;
@QField(backendName = "mic_algorithm", maxLength = 50)
private String micAlgorithm;
@QField(backendName = "mic_value", maxLength = 255)
private String micValue;
@QField(backendName = "signed")
private Boolean signed;
@QField(backendName = "signature_valid")
private Boolean signatureValid;
@QField(backendName = "payload_ref", maxLength = 255)
private String payloadRef;
@QField(backendName = "received_at")
private Instant receivedAt;
@QField(backendName = "transport_id", possibleValueSourceName = "edi_transport")
private Integer transportId;
@QField(backendName = "partner_id", possibleValueSourceName = "edi_partner")
private Integer partnerId;
@QField(backendName = "outbound_message_id", possibleValueSourceName = "edi_outbound_message")
private Integer outboundMessageId;
@QField(backendName = "inbox_id", possibleValueSourceName = "edi_inbox")
private Integer inboxId;
@QField(backendName = "delivery_url", maxLength = 1000)
private String deliveryUrl;
@QField(backendName = "delivery_status", maxLength = 20, possibleValueSourceName = "edi_as2_mdn_delivery_status")
private String deliveryStatus;
@QField(backendName = "delivery_attempts")
private Integer deliveryAttempts;
@QField(backendName = "next_attempt_at")
private Instant nextAttemptAt;
@QField(backendName = "delivered_at")
private Instant deliveredAt;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiAs2Mdn withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get direction.
*******************************************************************************/
public String getDirection()
{
return (direction);
}
/*******************************************************************************
** Set direction.
*******************************************************************************/
public void setDirection(String direction)
{
this.direction = direction;
}
/*******************************************************************************
** Set direction fluently.
*******************************************************************************/
public EdiAs2Mdn withDirection(String direction)
{
this.direction = direction;
return (this);
}
/*******************************************************************************
** Get messageId.
*******************************************************************************/
public String getMessageId()
{
return (messageId);
}
/*******************************************************************************
** Set messageId.
*******************************************************************************/
public void setMessageId(String messageId)
{
this.messageId = messageId;
}
/*******************************************************************************
** Set messageId fluently.
*******************************************************************************/
public EdiAs2Mdn withMessageId(String messageId)
{
this.messageId = messageId;
return (this);
}
/*******************************************************************************
** Get originalMessageId.
*******************************************************************************/
public String getOriginalMessageId()
{
return (originalMessageId);
}
/*******************************************************************************
** Set originalMessageId.
*******************************************************************************/
public void setOriginalMessageId(String originalMessageId)
{
this.originalMessageId = originalMessageId;
}
/*******************************************************************************
** Set originalMessageId fluently.
*******************************************************************************/
public EdiAs2Mdn withOriginalMessageId(String originalMessageId)
{
this.originalMessageId = originalMessageId;
return (this);
}
/*******************************************************************************
** Get disposition.
*******************************************************************************/
public String getDisposition()
{
return (disposition);
}
/*******************************************************************************
** Set disposition.
*******************************************************************************/
public void setDisposition(String disposition)
{
this.disposition = disposition;
}
/*******************************************************************************
** Set disposition fluently.
*******************************************************************************/
public EdiAs2Mdn withDisposition(String disposition)
{
this.disposition = disposition;
return (this);
}
/*******************************************************************************
** Get micAlgorithm.
*******************************************************************************/
public String getMicAlgorithm()
{
return (micAlgorithm);
}
/*******************************************************************************
** Set micAlgorithm.
*******************************************************************************/
public void setMicAlgorithm(String micAlgorithm)
{
this.micAlgorithm = micAlgorithm;
}
/*******************************************************************************
** Set micAlgorithm fluently.
*******************************************************************************/
public EdiAs2Mdn withMicAlgorithm(String micAlgorithm)
{
this.micAlgorithm = micAlgorithm;
return (this);
}
/*******************************************************************************
** Get micValue.
*******************************************************************************/
public String getMicValue()
{
return (micValue);
}
/*******************************************************************************
** Set micValue.
*******************************************************************************/
public void setMicValue(String micValue)
{
this.micValue = micValue;
}
/*******************************************************************************
** Set micValue fluently.
*******************************************************************************/
public EdiAs2Mdn withMicValue(String micValue)
{
this.micValue = micValue;
return (this);
}
/*******************************************************************************
** Get signed.
*******************************************************************************/
public Boolean getSigned()
{
return (signed);
}
/*******************************************************************************
** Set signed.
*******************************************************************************/
public void setSigned(Boolean signed)
{
this.signed = signed;
}
/*******************************************************************************
** Set signed fluently.
*******************************************************************************/
public EdiAs2Mdn withSigned(Boolean signed)
{
this.signed = signed;
return (this);
}
/*******************************************************************************
** Get signatureValid.
*******************************************************************************/
public Boolean getSignatureValid()
{
return (signatureValid);
}
/*******************************************************************************
** Set signatureValid.
*******************************************************************************/
public void setSignatureValid(Boolean signatureValid)
{
this.signatureValid = signatureValid;
}
/*******************************************************************************
** Set signatureValid fluently.
*******************************************************************************/
public EdiAs2Mdn withSignatureValid(Boolean signatureValid)
{
this.signatureValid = signatureValid;
return (this);
}
/*******************************************************************************
** Get payloadRef.
*******************************************************************************/
public String getPayloadRef()
{
return (payloadRef);
}
/*******************************************************************************
** Set payloadRef.
*******************************************************************************/
public void setPayloadRef(String payloadRef)
{
this.payloadRef = payloadRef;
}
/*******************************************************************************
** Set payloadRef fluently.
*******************************************************************************/
public EdiAs2Mdn withPayloadRef(String payloadRef)
{
this.payloadRef = payloadRef;
return (this);
}
/*******************************************************************************
** Get receivedAt.
*******************************************************************************/
public Instant getReceivedAt()
{
return (receivedAt);
}
/*******************************************************************************
** Set receivedAt.
*******************************************************************************/
public void setReceivedAt(Instant receivedAt)
{
this.receivedAt = receivedAt;
}
/*******************************************************************************
** Set receivedAt fluently.
*******************************************************************************/
public EdiAs2Mdn withReceivedAt(Instant receivedAt)
{
this.receivedAt = receivedAt;
return (this);
}
/*******************************************************************************
** Get transportId.
*******************************************************************************/
public Integer getTransportId()
{
return (transportId);
}
/*******************************************************************************
** Set transportId.
*******************************************************************************/
public void setTransportId(Integer transportId)
{
this.transportId = transportId;
}
/*******************************************************************************
** Set transportId fluently.
*******************************************************************************/
public EdiAs2Mdn withTransportId(Integer transportId)
{
this.transportId = transportId;
return (this);
}
/*******************************************************************************
** Get partnerId.
*******************************************************************************/
public Integer getPartnerId()
{
return (partnerId);
}
/*******************************************************************************
** Set partnerId.
*******************************************************************************/
public void setPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
}
/*******************************************************************************
** Set partnerId fluently.
*******************************************************************************/
public EdiAs2Mdn withPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
return (this);
}
/*******************************************************************************
** Get outboundMessageId.
*******************************************************************************/
public Integer getOutboundMessageId()
{
return (outboundMessageId);
}
/*******************************************************************************
** Set outboundMessageId.
*******************************************************************************/
public void setOutboundMessageId(Integer outboundMessageId)
{
this.outboundMessageId = outboundMessageId;
}
/*******************************************************************************
** Set outboundMessageId fluently.
*******************************************************************************/
public EdiAs2Mdn withOutboundMessageId(Integer outboundMessageId)
{
this.outboundMessageId = outboundMessageId;
return (this);
}
/*******************************************************************************
** Get inboxId.
*******************************************************************************/
public Integer getInboxId()
{
return (inboxId);
}
/*******************************************************************************
** Set inboxId.
*******************************************************************************/
public void setInboxId(Integer inboxId)
{
this.inboxId = inboxId;
}
/*******************************************************************************
** Set inboxId fluently.
*******************************************************************************/
public EdiAs2Mdn withInboxId(Integer inboxId)
{
this.inboxId = inboxId;
return (this);
}
/*******************************************************************************
** Get deliveryUrl.
*******************************************************************************/
public String getDeliveryUrl()
{
return (deliveryUrl);
}
/*******************************************************************************
** Set deliveryUrl.
*******************************************************************************/
public void setDeliveryUrl(String deliveryUrl)
{
this.deliveryUrl = deliveryUrl;
}
/*******************************************************************************
** Set deliveryUrl fluently.
*******************************************************************************/
public EdiAs2Mdn withDeliveryUrl(String deliveryUrl)
{
this.deliveryUrl = deliveryUrl;
return (this);
}
/*******************************************************************************
** Get deliveryStatus.
*******************************************************************************/
public String getDeliveryStatus()
{
return (deliveryStatus);
}
/*******************************************************************************
** Set deliveryStatus.
*******************************************************************************/
public void setDeliveryStatus(String deliveryStatus)
{
this.deliveryStatus = deliveryStatus;
}
/*******************************************************************************
** Set deliveryStatus fluently.
*******************************************************************************/
public EdiAs2Mdn withDeliveryStatus(String deliveryStatus)
{
this.deliveryStatus = deliveryStatus;
return (this);
}
/*******************************************************************************
** Get deliveryAttempts.
*******************************************************************************/
public Integer getDeliveryAttempts()
{
return (deliveryAttempts);
}
/*******************************************************************************
** Set deliveryAttempts.
*******************************************************************************/
public void setDeliveryAttempts(Integer deliveryAttempts)
{
this.deliveryAttempts = deliveryAttempts;
}
/*******************************************************************************
** Set deliveryAttempts fluently.
*******************************************************************************/
public EdiAs2Mdn withDeliveryAttempts(Integer deliveryAttempts)
{
this.deliveryAttempts = deliveryAttempts;
return (this);
}
/*******************************************************************************
** Get nextAttemptAt.
*******************************************************************************/
public Instant getNextAttemptAt()
{
return (nextAttemptAt);
}
/*******************************************************************************
** Set nextAttemptAt.
*******************************************************************************/
public void setNextAttemptAt(Instant nextAttemptAt)
{
this.nextAttemptAt = nextAttemptAt;
}
/*******************************************************************************
** Set nextAttemptAt fluently.
*******************************************************************************/
public EdiAs2Mdn withNextAttemptAt(Instant nextAttemptAt)
{
this.nextAttemptAt = nextAttemptAt;
return (this);
}
/*******************************************************************************
** Get deliveredAt.
*******************************************************************************/
public Instant getDeliveredAt()
{
return (deliveredAt);
}
/*******************************************************************************
** Set deliveredAt.
*******************************************************************************/
public void setDeliveredAt(Instant deliveredAt)
{
this.deliveredAt = deliveredAt;
}
/*******************************************************************************
** Set deliveredAt fluently.
*******************************************************************************/
public EdiAs2Mdn withDeliveredAt(Instant deliveredAt)
{
this.deliveredAt = deliveredAt;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiAs2Mdn withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiAs2Mdn withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -39,6 +39,9 @@ public class EdiOutboundMessage extends QRecordEntity
@QField(backendName = "transport_message_id", maxLength = 255)
private String transportMessageId;
@QField(backendName = "transport_mic", maxLength = 255)
private String transportMic;
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@@ -97,6 +100,31 @@ public class EdiOutboundMessage extends QRecordEntity
private Instant modifyDate;
/*******************************************************************************
** Get transportMic.
*******************************************************************************/
public String getTransportMic()
{
return (transportMic);
}
/*******************************************************************************
** Set transportMic.
*******************************************************************************/
public void setTransportMic(String value)
{
transportMic = value;
}
/*******************************************************************************
** Configure transportMic.
*******************************************************************************/
public EdiOutboundMessage withTransportMic(String value)
{
transportMic = value;
return (this);
}
/*******************************************************************************
** Get id.
*******************************************************************************/
@@ -81,6 +81,9 @@ public class EdiTransport extends QRecordEntity
@QField(backendName = "mdn_signed")
private Boolean mdnSigned;
@QField(backendName = "weak_crypto", isEditable = false)
private Boolean weakCrypto;
@QField(backendName = "local_certificate_id", possibleValueSourceName = "edi_certificate")
private Integer localCertificateId;
@@ -1583,4 +1586,29 @@ public class EdiTransport extends QRecordEntity
return (this);
}
/*******************************************************************************
** Get weakCrypto: true when SHA1 or 3DES is configured. Set by the qbit on save.
*******************************************************************************/
public Boolean getWeakCrypto()
{
return (weakCrypto);
}
/*******************************************************************************
** Set weakCrypto.
*******************************************************************************/
public void setWeakCrypto(Boolean weakCrypto)
{
this.weakCrypto = weakCrypto;
}
/*******************************************************************************
** Set weakCrypto fluently.
*******************************************************************************/
public EdiTransport withWeakCrypto(Boolean weakCrypto)
{
this.weakCrypto = weakCrypto;
return (this);
}
}
@@ -95,11 +95,13 @@ public final class EdiProcesses
result.add(action(table, "cancel", "Cancel", "edi.cancel"));
}
result.add(action("edi_transport", "test", "Test Connection", "edi.transport.test"));
result.add(action("edi_certificate", "promote", "Promote Certificate", "edi.certificate.promote"));
result.add(action("edi_certificate", "export", "Export Public Certificate", "edi.certificate.read"));
for(String table : config.getSendAsEdiTables())
{
result.add(action(table, "send", "Send As EDI", "edi.send"));
}
for(String name : List.of("edi_outbound_worker", "edi_send_worker", "edi_ack_monitor", "edi_outbound_enqueue"))
for(String name : List.of("edi_outbound_worker", "edi_send_worker", "edi_as2_mdn_sender", "edi_ack_monitor", "edi_outbound_enqueue"))
{
result.add(OutboundProcesses.process(name, config, name.equals("edi_outbound_enqueue") ? null : name.equals("edi_ack_monitor") ? 300 : 15, null));
}
@@ -135,6 +137,9 @@ public final class EdiProcesses
}
process.getFrontendStep("review").withFormField(new QFieldMetaData("sampleUpload", QFieldType.BLOB).withLabel("Upload synthetic sample (optional)")
.withFieldAdornment(com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType.FileUploadAdornment.newFieldAdornment()));
}
if(operation.equals("preview") || operation.equals("export"))
{
process.getFrontendStep("result").withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.DOWNLOAD_FORM));
for(String field : List.of("downloadFileName", "storageTableName", "storageReference"))
@@ -204,7 +209,16 @@ public final class EdiProcesses
var query = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(table).withFilter(EdiRecords.equal("id", selected.getValue("id"))).withShouldFetchHeavyFields(true);
query.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
QRecord row = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query).getRecords().getFirst();
if(operation.equals("preview"))
if(operation.equals("promote") && !"NEXT".equals(row.getValueString("status")))
{
lines.add(new ProcessSummaryLine(Status.WARNING, 1, "only a NEXT certificate can be promoted"));
continue;
}
if(operation.equals("export"))
{
lines.add(new ProcessSummaryLine(Status.OK, 1, "public certificate exported"));
}
else if(operation.equals("preview"))
{
QRecord map = table.equals("edi_map") ? row : EdiRecords.query("edi_map", EdiRecords.equal("id", row.getValue("mapId"))).getFirst();
List<QRecord> samples = table.equals("edi_map_sample") ? List.of(row) : EdiRecords.query("edi_map_sample", EdiRecords.equal("mapId", row.getValue("id")));
@@ -265,6 +279,10 @@ public final class EdiProcesses
.test(row.toEntity(com.kingsrook.qbits.edi.model.EdiTransport.class));
lines.add(new ProcessSummaryLine(Boolean.TRUE.equals(test.success()) ? Status.OK : Status.ERROR, 1, test.summary()));
}
else if(operation.equals("promote"))
{
new com.kingsrook.qbits.edi.as2.CertificateService().promote(row);
}
else if(List.of("assign", "ignore", "resolve").contains(operation))
{
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("status", operation.equals("assign") ? "IN_PROGRESS" : operation.equals("ignore") ? "IGNORED" : "RESOLVED");
@@ -286,14 +304,15 @@ public final class EdiProcesses
}));
lines.add(new ProcessSummaryLine(Status.OK, 1, "record processed"));
}
if(operation.equals("preview"))
if(operation.equals("preview") || operation.equals("export"))
{
String json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().writeValueAsString(lines);
boolean export = operation.equals("export");
String content = export ? row.getValueString("certificatePem") : new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().writeValueAsString(lines);
var ref = SystemWrites.call(() -> EdiRuntime.current().payloads().put(com.kingsrook.qbits.edi.storage.PayloadKind.SAMPLE,
new java.io.ByteArrayInputStream(json.getBytes(java.nio.charset.StandardCharsets.UTF_8))));
new java.io.ByteArrayInputStream(content.getBytes(java.nio.charset.StandardCharsets.UTF_8))));
var storage = new com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput(com.kingsrook.qbits.edi.storage.PreviewDownloadBackend.TABLE).withReference(ref.value());
com.kingsrook.qqq.backend.core.actions.processes.ProcessFileDownload.registerStorage(storage);
output.addValue("downloadFileName", "edi-map-preview.json");
output.addValue("downloadFileName", export ? row.getValueString("alias").replaceAll("[^A-Za-z0-9._-]", "-") + ".pem" : "edi-map-preview.json");
output.addValue("storageTableName", storage.getTableName());
output.addValue("storageReference", ref.value());
}
@@ -106,6 +106,10 @@ public class OutboundProcesses implements BackendStep
{
new SendWorker().runOnce();
}
else if(name.equals("edi_as2_mdn_sender"))
{
new com.kingsrook.qbits.edi.as2.As2MdnSender().runOnce();
}
else if(name.equals("edi_ack_monitor"))
{
new AckMonitor().runOnce();
@@ -26,7 +26,8 @@ import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.PayloadRef;
/*******************************************************************************
** Explicit phase boundaries for AS2 and the unpublished email API.
** Explicit boundary for the unpublished qbit-email API; AS2 is implemented by
** As2Transport.
*******************************************************************************/
public record UnavailableTransport(String type) implements EdiTransportPlugin
{
@@ -43,7 +44,7 @@ public record UnavailableTransport(String type) implements EdiTransportPlugin
*******************************************************************************/
public void validate(EdiTransport transport, List<String> errors)
{
if("AS2".equals(type) ? transport.getAs2Url() == null : transport.getEmailTo() == null)
if(transport.getEmailTo() == null)
{
errors.add("Transport destination is required");
}
@@ -54,6 +55,6 @@ public record UnavailableTransport(String type) implements EdiTransportPlugin
*******************************************************************************/
public EdiSendResult send(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport)
{
return (new EdiSendResult("FAILED", null, "Not available until phase c / qbit-email API"));
return (new EdiSendResult("FAILED", null, "Not available until the qbit-email API ships"));
}
}
@@ -0,0 +1,596 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.edi.security.EdiSecrets;
import com.sun.net.httpserver.HttpServer;
import jakarta.activation.DataHandler;
import jakarta.mail.internet.InternetHeaders;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.internet.MimeUtility;
import jakarta.mail.util.ByteArrayDataSource;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.cms.CMSAlgorithm;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoGeneratorBuilder;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
import org.bouncycastle.cms.jcajce.JceCMSContentEncryptorBuilder;
import org.bouncycastle.cms.jcajce.JceKeyTransEnvelopedRecipient;
import org.bouncycastle.cms.jcajce.JceKeyTransRecipientId;
import org.bouncycastle.cms.jcajce.JceKeyTransRecipientInfoGenerator;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.mail.smime.SMIMEEnveloped;
import org.bouncycastle.mail.smime.SMIMEEnvelopedGenerator;
import org.bouncycastle.mail.smime.SMIMESigned;
import org.bouncycastle.mail.smime.SMIMESignedGenerator;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
/*******************************************************************************
** Synthetic in-memory certificates and a BouncyCastle-only AS2 peer, written
** independently of the production classes so that both directions are tested
** against a second implementation. Nothing here touches the filesystem.
*******************************************************************************/
public final class As2Support
{
public static final Map<String, String> ENV = new ConcurrentHashMap<>();
private static final Map<String, Party> PARTIES = new ConcurrentHashMap<>();
/*******************************************************************************
** A synthetic self-signed certificate and its key pair.
*******************************************************************************/
public record Party(KeyPair pair, X509Certificate certificate)
{
/*******************************************************************************
** PEM of the public certificate.
*******************************************************************************/
public String certificatePem()
{
try
{
return ("-----BEGIN CERTIFICATE-----\n" + Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)).encodeToString(certificate.getEncoded()) + "\n-----END CERTIFICATE-----\n");
}
catch(Exception e)
{
throw new IllegalStateException(e);
}
}
/*******************************************************************************
** PEM PKCS#8 of the private key (synthetic, memory only).
*******************************************************************************/
public String keyPem()
{
return ("-----BEGIN PRIVATE KEY-----\n" + Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)).encodeToString(pair.getPrivate().getEncoded()) + "\n-----END PRIVATE KEY-----\n");
}
/*******************************************************************************
** The private key.
*******************************************************************************/
public PrivateKey key()
{
return (pair.getPrivate());
}
}
/*******************************************************************************
** A signed or unsigned reply from the peer or from the system under test.
*******************************************************************************/
public record Reply(int status, Map<String, String> headers, byte[] body)
{
/*******************************************************************************
** Body as text.
*******************************************************************************/
public String text()
{
return (new String(body, StandardCharsets.ISO_8859_1));
}
}
/*******************************************************************************
** What a peer learned from an MDN.
*******************************************************************************/
public record PeerMdn(String disposition, String originalMessageId, String receivedMic, Boolean signatureValid, String raw)
{
}
/*******************************************************************************
** A message the peer server received and unwrapped.
*******************************************************************************/
public record PeerReceived(String messageId, byte[] payload, String mic, boolean signed, boolean encrypted)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private As2Support()
{
}
/*******************************************************************************
** A named synthetic party, created once per test run. Validity runs from one
** day ago for the given number of days.
*******************************************************************************/
public static Party party(String name, long validDays)
{
return (PARTIES.computeIfAbsent(name + ":" + validDays, k -> create(name, Instant.now().minusSeconds(86400), Instant.now().plusSeconds(validDays * 86400))));
}
/*******************************************************************************
** Generate an RSA 2048 key pair and a self-signed certificate.
*******************************************************************************/
public static Party create(String name, Instant notBefore, Instant notAfter)
{
try
{
Security.addProvider(new BouncyCastleProvider());
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair pair = generator.generateKeyPair();
X500Name subject = new X500Name("CN=" + name + ",O=Synthetic Test");
X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(subject, BigInteger.valueOf(System.nanoTime()), Date.from(notBefore), Date.from(notAfter), subject, pair.getPublic());
X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate())));
return (new Party(pair, certificate));
}
catch(Exception e)
{
throw new IllegalStateException(e);
}
}
/*******************************************************************************
** Resolver for environment variables held only in memory; the payload key is
** always available.
*******************************************************************************/
public static EdiSecrets secrets()
{
return (name -> name.startsWith("QQQ_EDI_PAYLOAD") ? Base64.getEncoder().encodeToString(new byte[32]) : ENV.get(name));
}
/*******************************************************************************
** Base64 SHA-256 of bytes.
*******************************************************************************/
public static String sha256(byte[] bytes) throws Exception
{
return (Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(bytes)));
}
/*******************************************************************************
** Send an AS2 message built with BouncyCastle only.
*******************************************************************************/
public static Reply peerSend(String url, Map<String, String> ids, String messageId, byte[] payload, Party signer, X509Certificate encryptTo, boolean base64, boolean requestMdn, boolean signedMdn,
String asyncUrl) throws Exception
{
MimeBodyPart part = new MimeBodyPart();
part.setDataHandler(new DataHandler(new ByteArrayDataSource(payload, "application/edi-x12")));
part.setHeader("Content-Type", "application/edi-x12");
part.setHeader("Content-Transfer-Encoding", base64 ? "base64" : "binary");
part.setHeader("Content-Disposition", "attachment; filename=\"peer.edi\"");
String mic = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(serialize(part))) + ", sha-256";
MimeBodyPart outer = part;
if(signer != null)
{
SMIMESignedGenerator generator = new SMIMESignedGenerator();
generator.addSignerInfoGenerator(new JcaSimpleSignerInfoGeneratorBuilder().setProvider("BC").build("SHA256withRSA", signer.key(), signer.certificate()));
generator.addCertificates(new JcaCertStore(List.of(signer.certificate())));
MimeMultipart multipart = generator.generate(part);
outer = new MimeBodyPart();
outer.setContent(multipart);
outer.setHeader("Content-Type", multipart.getContentType());
}
if(encryptTo != null)
{
SMIMEEnvelopedGenerator generator = new SMIMEEnvelopedGenerator();
generator.addRecipientInfoGenerator(new JceKeyTransRecipientInfoGenerator(encryptTo).setProvider("BC"));
outer = generator.generate(outer, new JceCMSContentEncryptorBuilder(CMSAlgorithm.AES256_CBC).setProvider("BC").build());
}
byte[] wire = serialize(outer);
int split = indexOf(wire, "\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url)).POST(HttpRequest.BodyPublishers.ofByteArray(java.util.Arrays.copyOfRange(wire, split + 4, wire.length)))
.header("Content-Type", MimeUtility.unfold(outer.getHeader("Content-Type", null)));
if(outer.getHeader("Content-Transfer-Encoding", null) != null)
{
request.header("Content-Transfer-Encoding", outer.getHeader("Content-Transfer-Encoding", null));
}
ids.forEach(request::header);
request.header("AS2-Version", "1.2").header("Message-ID", "<" + messageId + ">");
if(requestMdn)
{
request.header("Disposition-Notification-To", "peer@example.invalid");
if(signedMdn)
{
request.header("Disposition-Notification-Options", "signed-receipt-protocol=required, pkcs7-signature; signed-receipt-micalg=required, sha-256");
}
if(asyncUrl != null)
{
request.header("Receipt-Delivery-Option", asyncUrl);
}
}
try(HttpClient client = HttpClient.newHttpClient())
{
HttpResponse<byte[]> response = client.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
Map<String, String> headers = new LinkedHashMap<>();
response.headers().map().forEach((k, v) -> headers.put(k.toLowerCase(java.util.Locale.ROOT), v.getFirst()));
headers.put("x-sent-mic", mic);
return (new Reply(response.statusCode(), headers, response.body()));
}
}
/*******************************************************************************
** Read an MDN with BouncyCastle, verifying a signature with the given
** certificate when the MDN is signed.
*******************************************************************************/
public static PeerMdn peerReadMdn(Reply reply, X509Certificate signer) throws Exception
{
String contentType = reply.headers().get("content-type");
byte[] body = reply.body();
Boolean valid = null;
MimeMultipart report;
if(contentType.toLowerCase(java.util.Locale.ROOT).startsWith("multipart/signed"))
{
SMIMESigned signed = new SMIMESigned(new MimeMultipart(new ByteArrayDataSource(body, contentType)));
valid = false;
for(SignerInformation info : signed.getSignerInfos().getSigners())
{
valid = info.verify(new JcaSimpleSignerInfoVerifierBuilder().setProvider("BC").build(signer));
}
MimeBodyPart content = signed.getContent();
report = new MimeMultipart(new ByteArrayDataSource(content.getRawInputStream().readAllBytes(), MimeUtility.unfold(content.getContentType())));
}
else
{
report = new MimeMultipart(new ByteArrayDataSource(body, contentType));
}
for(int i = 0; i < report.getCount(); i++)
{
if(report.getBodyPart(i).getContentType().toLowerCase(java.util.Locale.ROOT).startsWith("message/disposition-notification"))
{
InternetHeaders fields = new InternetHeaders(new ByteArrayInputStream(report.getBodyPart(i).getInputStream().readAllBytes()));
String[] mic = fields.getHeader("Received-Content-MIC");
return (new PeerMdn(fields.getHeader("Disposition")[0].trim(), fields.getHeader("Original-Message-ID")[0].trim(), mic == null ? null : mic[0].trim(), valid, new String(body, StandardCharsets.ISO_8859_1)));
}
}
throw new IllegalStateException("no disposition notification");
}
/*******************************************************************************
** A BouncyCastle AS2 receiver on a random loopback port. It records what it
** decrypts and verifies, and answers with an MDN it builds itself.
*******************************************************************************/
public static final class PeerServer implements AutoCloseable
{
public final HttpServer server;
public final List<PeerReceived> received = new CopyOnWriteArrayList<>();
public final List<Map<String, String>> requests = new CopyOnWriteArrayList<>();
public final AtomicReference<String> disposition = new AtomicReference<>("automatic-action/MDN-sent-automatically; processed");
public final AtomicReference<Boolean> corruptMic = new AtomicReference<>(false);
public final AtomicReference<Boolean> signMdn = new AtomicReference<>(true);
public final AtomicReference<Boolean> async = new AtomicReference<>(false);
public final AtomicReference<Boolean> silent = new AtomicReference<>(false);
public final AtomicReference<Reply> asyncResult = new AtomicReference<>();
public final List<Party> decryptionKeys = new CopyOnWriteArrayList<>();
public final List<X509Certificate> senderCertificates = new CopyOnWriteArrayList<>();
public volatile Party self;
/*******************************************************************************
** Start listening; self signs MDNs and decrypts messages.
*******************************************************************************/
public PeerServer(Party self, X509Certificate sender) throws Exception
{
this.self = self;
decryptionKeys.add(self);
senderCertificates.add(sender);
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/as2", exchange ->
{
try
{
if("OPTIONS".equals(exchange.getRequestMethod()))
{
exchange.sendResponseHeaders(200, -1);
return;
}
byte[] body = exchange.getRequestBody().readAllBytes();
Map<String, String> headers = new LinkedHashMap<>();
exchange.getRequestHeaders().forEach((k, v) -> headers.put(k.toLowerCase(java.util.Locale.ROOT), v.getFirst()));
requests.add(headers);
byte[] response = handle(headers, body);
if(response == null)
{
exchange.sendResponseHeaders(200, -1);
}
else
{
int split = indexOf(response, "\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
String head = new String(response, 0, split, StandardCharsets.ISO_8859_1);
for(String line : head.split("\r\n"))
{
if(line.toLowerCase(java.util.Locale.ROOT).startsWith("content-type:"))
{
exchange.getResponseHeaders().add("Content-Type", line.substring(13).trim());
}
}
exchange.getResponseHeaders().add("AS2-Version", "1.2");
exchange.getResponseHeaders().add("Message-ID", "<peer-mdn-" + System.nanoTime() + "@peer>");
byte[] out = java.util.Arrays.copyOfRange(response, split + 4, response.length);
exchange.sendResponseHeaders(200, out.length);
exchange.getResponseBody().write(out);
}
}
catch(Exception e)
{
exchange.sendResponseHeaders(500, -1);
}
finally
{
exchange.close();
}
});
server.start();
}
/*******************************************************************************
** URL of the AS2 endpoint.
*******************************************************************************/
public String url()
{
return ("http://127.0.0.1:" + server.getAddress().getPort() + "/as2");
}
/*******************************************************************************
** Unwrap, verify and answer one message.
*******************************************************************************/
private byte[] handle(Map<String, String> headers, byte[] body) throws Exception
{
String contentType = headers.get("content-type");
String encoding = headers.get("content-transfer-encoding");
MimeBodyPart part = new MimeBodyPart(new ByteArrayInputStream(concat(("Content-Type: " + contentType + "\r\n" + (encoding == null ? "" : "Content-Transfer-Encoding: " + encoding + "\r\n") + "\r\n").getBytes(StandardCharsets.ISO_8859_1), body)));
boolean encrypted = false;
boolean signed = false;
if(part.getContentType().toLowerCase(java.util.Locale.ROOT).startsWith("application/pkcs7-mime"))
{
SMIMEEnveloped enveloped = new SMIMEEnveloped(part);
byte[] decrypted = null;
for(Party key : decryptionKeys)
{
var recipient = enveloped.getRecipientInfos().get(new JceKeyTransRecipientId(key.certificate()));
if(recipient != null)
{
decrypted = recipient.getContent(new JceKeyTransEnvelopedRecipient(key.key()).setProvider("BC"));
}
}
if(decrypted == null)
{
throw new IllegalStateException("not encrypted to this peer");
}
part = new MimeBodyPart(new ByteArrayInputStream(decrypted));
encrypted = true;
}
MimeBodyPart content = part;
if(part.getContentType().toLowerCase(java.util.Locale.ROOT).startsWith("multipart/signed"))
{
SMIMESigned smime = new SMIMESigned(new MimeMultipart(new ByteArrayDataSource(part.getRawInputStream().readAllBytes(), MimeUtility.unfold(part.getContentType()))));
boolean verified = false;
for(SignerInformation info : smime.getSignerInfos().getSigners())
{
for(X509Certificate certificate : senderCertificates)
{
verified |= info.verify(new JcaSimpleSignerInfoVerifierBuilder().setProvider("BC").build(certificate));
}
}
if(!verified)
{
throw new IllegalStateException("bad signature");
}
content = smime.getContent();
signed = true;
}
String mic = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(serialize(content))) + ", sha-256";
String messageId = headers.get("message-id").replaceAll("[<>]", "");
received.add(new PeerReceived(messageId, content.getInputStream().readAllBytes(), mic, signed, encrypted));
String reportedMic = corruptMic.get() ? Base64.getEncoder().encodeToString(new byte[32]) + ", sha-256" : mic;
byte[] mdn = mdn(messageId, disposition.get(), reportedMic, signMdn.get() && headers.containsKey("disposition-notification-options"));
if(silent.get())
{
return (null);
}
if(async.get())
{
String target = headers.get("receipt-delivery-option");
Thread.startVirtualThread(() ->
{
try
{
asyncResult.set(postMdn(target, mdn, headers));
}
catch(Exception e)
{
asyncResult.set(new Reply(-1, Map.of(), e.toString().getBytes(StandardCharsets.UTF_8)));
}
});
return (null);
}
return (mdn);
}
/*******************************************************************************
** Post an asynchronous MDN to the address from the request.
*******************************************************************************/
private Reply postMdn(String target, byte[] mdn, Map<String, String> request) throws Exception
{
int split = indexOf(mdn, "\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
String head = new String(mdn, 0, split, StandardCharsets.ISO_8859_1);
String contentType = head.lines().filter(l -> l.toLowerCase(java.util.Locale.ROOT).startsWith("content-type:")).findFirst().orElseThrow().substring(13).trim();
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target)).header("Content-Type", contentType).header("AS2-From", request.get("as2-to")).header("AS2-To", request.get("as2-from"))
.header("AS2-Version", "1.2").header("Message-ID", "<peer-async-" + System.nanoTime() + "@peer>")
.POST(HttpRequest.BodyPublishers.ofByteArray(java.util.Arrays.copyOfRange(mdn, split + 4, mdn.length)));
try(HttpClient client = HttpClient.newHttpClient())
{
HttpResponse<byte[]> response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
return (new Reply(response.statusCode(), Map.of(), response.body()));
}
}
/*******************************************************************************
** An MDN entity (headers and body), built with BouncyCastle.
*******************************************************************************/
public byte[] mdn(String originalMessageId, String disposition, String mic, boolean signed) throws Exception
{
MimeMultipart report = new MimeMultipart("report; report-type=disposition-notification");
MimeBodyPart text = new MimeBodyPart();
text.setText("Received by the synthetic peer", "us-ascii");
report.addBodyPart(text);
InternetHeaders headers = new InternetHeaders();
headers.addHeader("Content-Type", "message/disposition-notification");
headers.addHeader("Content-Transfer-Encoding", "7bit");
String fields = "Reporting-UA: synthetic-peer\r\nOriginal-Recipient: rfc822; peer\r\nFinal-Recipient: rfc822; peer\r\nOriginal-Message-ID: <" + originalMessageId + ">\r\nDisposition: " + disposition + "\r\n"
+ (disposition.contains("error") ? "" : "Received-Content-MIC: " + mic + "\r\n");
report.addBodyPart(new MimeBodyPart(headers, fields.getBytes(StandardCharsets.US_ASCII)));
MimeBodyPart outer = new MimeBodyPart();
outer.setContent(report);
outer.setHeader("Content-Type", report.getContentType());
if(signed)
{
SMIMESignedGenerator generator = new SMIMESignedGenerator();
generator.addSignerInfoGenerator(new JcaSimpleSignerInfoGeneratorBuilder().setProvider("BC").build("SHA256withRSA", self.key(), self.certificate()));
MimeMultipart multipart = generator.generate(outer);
outer = new MimeBodyPart();
outer.setContent(multipart);
outer.setHeader("Content-Type", multipart.getContentType());
}
return (serialize(outer));
}
/*******************************************************************************
** Stop the server.
*******************************************************************************/
@Override
public void close()
{
server.stop(0);
}
}
/*******************************************************************************
** POST a MIME entity (headers and body) the way an AS2 partner does: the entity
** Content-Type becomes the HTTP Content-Type and the body the HTTP body.
*******************************************************************************/
public static Reply post(String url, byte[] entity, Map<String, String> headers) throws Exception
{
int split = indexOf(entity, "\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
String head = new String(entity, 0, split, StandardCharsets.ISO_8859_1);
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url)).POST(HttpRequest.BodyPublishers.ofByteArray(java.util.Arrays.copyOfRange(entity, split + 4, entity.length)));
for(String line : head.split("\r\n"))
{
if(line.toLowerCase(java.util.Locale.ROOT).startsWith("content-type:"))
{
request.header("Content-Type", line.substring(13).trim());
}
}
headers.forEach(request::header);
try(HttpClient client = HttpClient.newHttpClient())
{
HttpResponse<byte[]> response = client.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
Map<String, String> responseHeaders = new LinkedHashMap<>();
response.headers().map().forEach((k, v) -> responseHeaders.put(k.toLowerCase(java.util.Locale.ROOT), v.getFirst()));
return (new Reply(response.statusCode(), responseHeaders, response.body()));
}
}
/*******************************************************************************
** Serialize a MIME entity to bytes.
*******************************************************************************/
public static byte[] serialize(MimeBodyPart part) throws Exception
{
ByteArrayOutputStream out = new ByteArrayOutputStream();
part.writeTo(out);
return (out.toByteArray());
}
/*******************************************************************************
** Concatenate byte arrays.
*******************************************************************************/
public static byte[] concat(byte[] first, byte[] second)
{
byte[] all = new byte[first.length + second.length];
System.arraycopy(first, 0, all, 0, first.length);
System.arraycopy(second, 0, all, first.length, second.length);
return (all);
}
/*******************************************************************************
** Index of a sequence.
*******************************************************************************/
public static int indexOf(byte[] data, byte[] pattern)
{
for(int i = 0; i <= data.length - pattern.length; i++)
{
if(java.util.Arrays.equals(data, i, i + pattern.length, pattern, 0, pattern.length))
{
return (i);
}
}
return (-1);
}
/*******************************************************************************
** Names of everything directly under the JVM temp directory.
*******************************************************************************/
public static List<String> tempNames() throws Exception
{
List<String> names = new ArrayList<>();
try(var stream = java.nio.file.Files.list(java.nio.file.Path.of(System.getProperty("java.io.tmpdir"))))
{
stream.forEach(p -> names.add(p.getFileName().toString()));
}
return (names);
}
}
File diff suppressed because it is too large. Load diff
@@ -114,7 +114,8 @@ class ContingenciesTest
{
EdiRecords.insert("edi_identity", new QRecord().withValue("partnerId", partner).withValue("standard", "X12").withValue("interchangeQualifier", "ZZ")
.withValue("interchangeId", "SYNTH" + partner).withValue("applicationCode", "APP" + partner));
EdiRecords.insert("edi_certificate", new QRecord().withValue("partnerId", partner).withValue("alias", "Synthetic " + partner));
EdiRecords.insert("edi_certificate", new QRecord().withValue("partnerId", partner).withValue("alias", "Synthetic " + partner)
.withValue("certificatePem", As2Support.party("contingency-" + partner, 365).certificatePem()));
}
return (null);
});
@@ -110,7 +110,7 @@ class DatabaseTest
void metadata() throws Exception
{
QInstance instance = H2Support.create();
assertThat(instance.getTables()).hasSize(27);
assertThat(instance.getTables()).hasSize(28);
for(QTableMetaData table : instance.getTables().values())
{
if(table.getName().equals("edi_preview_download"))
@@ -60,14 +60,14 @@ class EdiPresentationTest
{
var instance = H2Support.create();
Set<String> externalIds = Set.of("interchangeId", "senderId", "receiverId", "elementId", "segmentId", "loopId", "parentLoopId",
"as2LocalId", "as2PartnerId", "externalId", "transportMessageId", "eventId", "sourceRecordId", "targetRecordId", "keyId", "gatewayUserId", "assignedToUserId");
"as2LocalId", "as2PartnerId", "messageId", "originalMessageId", "externalId", "transportMessageId", "eventId", "sourceRecordId", "targetRecordId", "keyId", "gatewayUserId", "assignedToUserId");
for(var table : instance.getTables().values())
{
assertThat(table.getLabel()).as(table.getName()).matches("[A-Z][A-Za-z0-9]*( [A-Z][A-Za-z0-9]*)*");
for(var field : table.getFields().values())
{
assertThat(field.getLabel()).as(table.getName() + "." + field.getName())
.doesNotContainPattern("\\b(Id|Phi|Pii|Url|Isa|Gs|St|As2|Sftp|Https|Http|Mdn|Tls|Ta1|Edi|Upc|Npi|Qqq|Json|Pem|Snip|Van)\\b");
.doesNotContainPattern("\\b(Id|Phi|Pii|Url|Isa|Gs|St|As2|Sftp|Https|Http|Mdn|Mic|Tls|Ta1|Edi|Upc|Npi|Qqq|Json|Pem|Snip|Van)\\b");
if(field.getName().endsWith("Id") && !(field.getType() == QFieldType.STRING && externalIds.contains(field.getName())))
{
assertThat(field.getPossibleValueSourceName()).as(table.getName() + "." + field.getName()).isNotBlank();
@@ -85,6 +85,9 @@ class EdiPresentationTest
}
assertThat(instance.getTable("edi_exception").getField("assignedToUserId").getLabel()).isEqualTo("Assigned To");
assertThat(instance.getTable("edi_exception").getField("outboundMessageId").getPossibleValueSourceName()).isEqualTo("edi_outbound_message");
assertThat(EdiTables.childWidgets()).extracting(widget -> widget.getLabel()).contains("AS2 MDNs");
assertThat(instance.getTable("edi_as2_mdn").getLabel()).isEqualTo("AS2 MDN");
assertThat(instance.getTable("edi_as2_mdn").getField("micValue").getLabel()).isEqualTo("MIC Value");
assertThat(EdiTables.childWidgets()).allSatisfy(widget -> assertThat(widget.getLabel()).doesNotContain("(", "_", "transaction", "identity"));
}
@@ -215,13 +218,15 @@ class EdiPresentationTest
case "edi_inbox" -> "SFTP demo-message";
case "edi_outbound_message" -> "Demo SFTP SENT";
case "edi_map", "edi_guide" -> "Retail r2";
case "edi_as2_mdn" -> "INBOUND MDN <synthetic-message@peer>";
default -> null;
};
if(expected != null)
{
QRecord row = new QRecord().withValue("direction", "INBOUND").withValue("controlNumber", "000001").withValue("type", "VALIDATION")
.withValue("summary", "Synthetic summary").withValue("transportType", "SFTP").withValue("externalId", "demo-message")
.withValue("fileName", "SYNTHETIC-PRIVATE-FILENAME").withValue("transportId", 1).withValue("status", "SENT").withValue("name", "Retail").withValue("revision", 2);
.withValue("fileName", "SYNTHETIC-PRIVATE-FILENAME").withValue("transportId", 1).withValue("status", "SENT").withValue("name", "Retail").withValue("revision", 2)
.withValue("originalMessageId", "<synthetic-message@peer>");
row.setDisplayValue("transportId", "Demo SFTP");
assertThat(QValueFormatter.formatRecordLabel(table, row)).as(table.getName()).isEqualTo(expected);
}
@@ -64,7 +64,7 @@ class EntityContractTest
*******************************************************************************/
static Stream<Class<? extends QRecordEntity>> entities()
{
return (Stream.of(EdiPartner.class, EdiIdentity.class, EdiAgreement.class, EdiAgreementDocument.class, EdiControlNumber.class, EdiTransport.class, EdiCertificate.class, EdiGuide.class, EdiGuideSegment.class, EdiGuideElement.class, EdiGuideRule.class, EdiCodeList.class, EdiCodeListValue.class, EdiInterchange.class, EdiFunctionalGroup.class, EdiTransaction.class, EdiTransactionError.class, EdiEvent.class, EdiException.class, EdiInbox.class, EdiPayload.class, com.kingsrook.qbits.edi.model.EdiMap.class, com.kingsrook.qbits.edi.model.EdiMapRule.class, com.kingsrook.qbits.edi.model.EdiMapSample.class, com.kingsrook.qbits.edi.model.EdiOutboundRequest.class, com.kingsrook.qbits.edi.model.EdiOutboundMessage.class));
return (Stream.of(EdiPartner.class, EdiIdentity.class, EdiAgreement.class, EdiAgreementDocument.class, EdiControlNumber.class, EdiTransport.class, EdiCertificate.class, EdiGuide.class, EdiGuideSegment.class, EdiGuideElement.class, EdiGuideRule.class, EdiCodeList.class, EdiCodeListValue.class, EdiInterchange.class, EdiFunctionalGroup.class, EdiTransaction.class, EdiTransactionError.class, EdiEvent.class, EdiException.class, EdiInbox.class, EdiPayload.class, com.kingsrook.qbits.edi.model.EdiMap.class, com.kingsrook.qbits.edi.model.EdiMapRule.class, com.kingsrook.qbits.edi.model.EdiMapSample.class, com.kingsrook.qbits.edi.model.EdiOutboundRequest.class, com.kingsrook.qbits.edi.model.EdiOutboundMessage.class, com.kingsrook.qbits.edi.model.EdiAs2Mdn.class));
}
/*******************************************************************************
@@ -273,8 +273,9 @@ class OutboundTest
assertThat(rows("edi_exception")).filteredOn(r -> "OVERDUE_ACK".equals(r.getValueString("type"))).allMatch(r -> "RESOLVED".equals(r.getValueString("status")));
SystemWrites.call(() ->
{
SystemWrites.insert("edi_certificate", new QRecord().withValue("alias", "synthetic-expiring").withValue("status", "ACTIVE").withValue("notAfter", Instant.now().plusSeconds(6 * 86400)));
SystemWrites.insert("edi_certificate", new QRecord().withValue("alias", "synthetic-expired").withValue("status", "ACTIVE").withValue("notAfter", Instant.now().minusSeconds(1)));
SystemWrites.insert("edi_certificate", new QRecord().withValue("alias", "synthetic-expiring").withValue("status", "ACTIVE").withValue("certificatePem", As2Support.party("synthetic-expiring", 6).certificatePem()));
SystemWrites.insert("edi_certificate", new QRecord().withValue("alias", "synthetic-expired").withValue("status", "ACTIVE")
.withValue("certificatePem", As2Support.create("synthetic-expired", Instant.now().minusSeconds(2 * 86400), Instant.now().minusSeconds(1)).certificatePem()));
return (null);
});
new AckMonitor().runOnce();
@@ -29,6 +29,7 @@ import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.edi.As2Support;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.H2Support;
import com.kingsrook.qbits.edi.LogisticsSupport;
@@ -73,6 +74,9 @@ public class EdiDemoServer extends AbstractQQQApplication implements AutoCloseab
private Map<String, String> authenticationModules;
private String previousAuthentication;
private final Map<String, byte[]> outbox = new java.util.concurrent.ConcurrentHashMap<>();
private final Map<String, String> secrets = new java.util.concurrent.ConcurrentHashMap<>();
private As2Support.PeerServer peer;
private Javalin hooks;
/*******************************************************************************
** Allocate a fresh database; payload keys and certificate keys stay in memory.
@@ -80,6 +84,7 @@ public class EdiDemoServer extends AbstractQQQApplication implements AutoCloseab
public EdiDemoServer() throws Exception
{
instance = H2Support.create();
EdiRuntime.current().config().withSecretsResolver(name -> secrets.getOrDefault(name, Base64.getEncoder().encodeToString(new byte[32])));
RetailSupport.tables();
LogisticsSupport.tables();
instance.setHomeAppName("edi");
@@ -239,6 +244,7 @@ public class EdiDemoServer extends AbstractQQQApplication implements AutoCloseab
installOutbox();
}
logistics(local, agreements.get(1));
seedAs2(local);
return (null);
});
}
@@ -319,6 +325,111 @@ public class EdiDemoServer extends AbstractQQQApplication implements AutoCloseab
SystemWrites.insert("edi_agreement_document", document);
}
/*******************************************************************************
** Pacific Wholesale over AS2: an in-process BouncyCastle peer on a random port,
** synthetic certificates generated in memory (keys only in the demo secrets map,
** never written anywhere), three 850s received over AS2, an 855 sent with a
** synchronous MDN and one asynchronous MDN example.
*******************************************************************************/
private void seedAs2(QRecord local) throws Exception
{
var ours = As2Support.party("demo-qqq", 365);
var oursNext = As2Support.party("demo-qqq-next", 400);
var pacific = As2Support.party("demo-pacific", 365);
secrets.put("DEMO_AS2_KEY", ours.keyPem());
secrets.put("DEMO_AS2_KEY_NEXT", oursNext.keyPem());
peer = new As2Support.PeerServer(pacific, ours.certificate());
var provider = new com.kingsrook.qbits.edi.as2.As2RouteProvider();
provider.setQInstance(instance);
hooks = Javalin.create(provider::acceptJavalinConfig).start(0);
EdiRuntime.current().config().withPublicBaseUrl("http://127.0.0.1:" + hooks.port());
QRecord partner = SystemWrites.insert("edi_partner", new QRecord().withValue("name", "Pacific Wholesale").withValue("code", "PACIFIC").withValue("contactName", "Pacific Wholesale Demo Contact")
.withValue("contactEmail", "pacific@example.invalid").withValue("contactPhone", "202-555-0150").withValue("status", "ACTIVE").withValue("defaultTimezone", "America/Los_Angeles"));
QRecord remote = SystemWrites.insert("edi_identity", new QRecord().withValue("partnerId", partner.getValue("id")).withValue("standard", "X12").withValue("interchangeQualifier", "ZZ")
.withValue("interchangeId", "PACIFICWS").withValue("applicationCode", "PACIFICWS").withValue("isActive", true));
QRecord oursActive = as2Certificate("demo-as2-active", ours, null, "ACTIVE", "DEMO_AS2_KEY");
as2Certificate("demo-as2-next", oursNext, null, "NEXT", "DEMO_AS2_KEY_NEXT");
QRecord theirs = as2Certificate("demo-pacific", pacific, partner.getValueInteger("id"), "ACTIVE", null);
QRecord sync = as2Transport("pacific-as2", "BOTH", partner, oursActive, theirs, "SYNC");
QRecord async = as2Transport("pacific-as2-async", "OUTBOUND", partner, oursActive, theirs, "ASYNC");
QRecord agreement = SystemWrites.insert("edi_agreement", new QRecord().withValue("partnerId", partner.getValue("id")).withValue("localIdentityId", local.getValue("id"))
.withValue("partnerIdentityId", remote.getValue("id")).withValue("standard", "X12").withValue("usageIndicator", "T").withValue("interchangeVersion", "00401")
.withValue("isActive", true).withValue("applyTestDocuments", true).withValue("inboundTransportId", sync.getValue("id")).withValue("outboundTransportId", sync.getValue("id")));
for(String document : List.of("850", "997"))
{
QRecord map = document.equals("997") ? null : RetailSupport.installMap(document, "004010");
QRecord guide = EdiRecords.query("edi_guide", EdiRecords.equal("transactionSet", document).withCriteria("version", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, "004010")).getFirst();
SystemWrites.insert("edi_agreement_document", new QRecord().withValue("agreementId", agreement.getValue("id")).withValue("direction", "INBOUND")
.withValue("transactionSet", document).withValue("version", "004010").withValue("guideId", guide.getValue("id")).withValue("mapId", map == null ? null : map.getValue("id"))
.withValue("ackExpectation", document.equals("997") ? "NONE" : "997").withValue("ackLevel", "ALL_TRANSACTIONS").withValue("isActive", true));
}
QRecord map855 = RetailSupport.installMap("855", "004010");
SystemWrites.insert("edi_agreement_document", new QRecord().withValue("agreementId", agreement.getValue("id")).withValue("direction", "OUTBOUND").withValue("transactionSet", "855")
.withValue("functionalIdCode", EdiRuntime.current().guides().resource("855-004010").functionalIdCode()).withValue("version", "004010").withValue("guideId", map855.getValue("guideId"))
.withValue("mapId", map855.getValue("id")).withValue("sourceTableName", map855.getValueString("tableName")).withValue("outboundTrigger", "MANUAL").withValue("batchMode", "IMMEDIATE")
.withValue("ackExpectation", "997").withValue("ackDueMinutes", 1440).withValue("isActive", true));
String hook = "http://127.0.0.1:" + hooks.port() + "/qqq/hooks/edi/as2/pacific-as2";
for(int i = 0; i < 3; i++)
{
String text = Synthetic850.generate(50 + i, "004010", X12Separators.DEFAULT).replace("SYNTHETIC", "PACIFICWS")
.replace("000000001", String.format("%09d", 300 + i)).replace("*1200*1*X*", "*1200*" + (300 + i) + "*X*").replace("GE*1*1~", "GE*1*" + (300 + i) + "~");
As2Support.peerSend(hook, Map.of("AS2-From", "PACIFIC-WS", "AS2-To", "QQQ-DEMO"), "demo-as2-" + i + "@pacific", text.getBytes(StandardCharsets.US_ASCII), pacific, ours.certificate(), i != 1, true, true, null);
}
while(new InboxWorker().runOnce() > 0)
{
// Drain the bounded inbox in the demo setup only.
}
QRecord order = SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source().withValue("poNumber", "PACIFIC-2001")));
OutboundEnqueue.enqueue("sample_order", order, "MANUAL", null);
new OutboundWorker().runOnce();
while(new SendWorker().runOnce() > 0)
{
// Deliver the 855 over AS2; its MDN is verified and recorded.
}
QRecord delivered = EdiRecords.query("edi_outbound_message", EdiRecords.equal("transportId", sync.getValue("id"))).getFirst();
peer.async.set(true);
PayloadRef copy = EdiRuntime.current().payloads().put(com.kingsrook.qbits.edi.storage.PayloadKind.OUTBOUND,
EdiRuntime.current().payloads().open(new PayloadRef(delivered.getValueString("payloadRef"), null, 0)));
QRecord example = SystemWrites.insert("edi_outbound_message", new QRecord().withValue("agreementId", agreement.getValue("id")).withValue("partnerId", partner.getValue("id"))
.withValue("transportId", async.getValue("id")).withValue("payloadRef", copy.value()).withValue("payloadSha256", copy.sha256()).withValue("sizeBytes", copy.sizeBytes())
.withValue("status", "QUEUED").withValue("attempts", 0));
new SendWorker().runOnce();
for(int i = 0; i < 100 && peer.asyncResult.get() == null; i++)
{
Thread.sleep(50);
}
peer.async.set(false);
hooks.stop();
hooks = null;
EdiRuntime.current().config().withPublicBaseUrl(null);
if(!"DELIVERED".equals(EdiRecords.query("edi_outbound_message", EdiRecords.equal("id", example.getValue("id"))).getFirst().getValueString("status")))
{
throw new IllegalStateException("The asynchronous AS2 example was not delivered");
}
}
/*******************************************************************************
** An AS2 certificate row; the qbit parses the public certificate on save.
*******************************************************************************/
private static QRecord as2Certificate(String alias, As2Support.Party party, Integer partnerId, String status, String keyVariable) throws Exception
{
return (SystemWrites.insert("edi_certificate", new QRecord().withValue("alias", alias).withValue("partnerId", partnerId).withValue("usage", "SIGNING_AND_ENCRYPTION")
.withValue("certificatePem", party.certificatePem()).withValue("privateKeyEnvVar", keyVariable).withValue("status", status)));
}
/*******************************************************************************
** An AS2 transport to the in-process peer: signed, encrypted, signed MDN.
*******************************************************************************/
private QRecord as2Transport(String name, String direction, QRecord partner, QRecord local, QRecord theirs, String mdnMode) throws Exception
{
return (SystemWrites.insert("edi_transport", new QRecord().withValue("partnerId", partner.getValue("id")).withValue("name", name).withValue("type", "AS2").withValue("direction", direction)
.withValue("isActive", true).withValue("as2LocalId", "QQQ-DEMO").withValue("as2PartnerId", "PACIFIC-WS").withValue("as2Url", peer.url()).withValue("signAlgorithm", "SHA256")
.withValue("encryptAlgorithm", "AES256_CBC").withValue("mdnMode", mdnMode).withValue("mdnSigned", true).withValue("localCertificateId", local.getValue("id"))
.withValue("partnerCertificateId", theirs.getValue("id")).withValue("maxAttempts", 3)));
}
/*******************************************************************************
** Synthetic change order retains a valid envelope and guide-compatible details.
*******************************************************************************/
@@ -391,6 +502,7 @@ public class EdiDemoServer extends AbstractQQQApplication implements AutoCloseab
new QInstanceValidator().validate(instance);
server = new QApplicationJavalinServer(this).withPort(port).withServeFrontendNext(true).withJavalinConfigurationCustomizer(app -> javalin = app);
server.start();
EdiRuntime.current().config().withPublicBaseUrl("http://localhost:" + javalin.port());
return (javalin.port());
}
@@ -409,6 +521,14 @@ public class EdiDemoServer extends AbstractQQQApplication implements AutoCloseab
authenticationModules.put(QAuthenticationType.FULLY_ANONYMOUS.getName(), previousAuthentication);
}
FileGatewayOutbox.register(null);
if(hooks != null)
{
hooks.stop();
}
if(peer != null)
{
peer.close();
}
QContext.clear();
}
@@ -86,15 +86,15 @@ class EdiDemoServerTest
demo.seed();
demo.seed();
assertThat(demo.defineQInstance().getHomeAppName()).isEqualTo("edi");
assertThat(SystemWrites.call(() -> EdiRecords.query("edi_partner", new QQueryFilter()))).hasSize(4);
assertThat(SystemWrites.call(() -> EdiRecords.query("edi_certificate", new QQueryFilter()))).hasSize(2);
assertThat(SystemWrites.call(() -> EdiRecords.query("edi_partner", new QQueryFilter()))).hasSize(5);
assertThat(SystemWrites.call(() -> EdiRecords.query("edi_certificate", new QQueryFilter()))).hasSize(5);
assertThat(SystemWrites.call(() -> EdiRecords.query("edi_inbox", new QQueryFilter()))).hasSizeGreaterThanOrEqualTo(40);
assertThat(SystemWrites.call(() -> EdiRecords.query("edi_transaction", new QQueryFilter()))).anyMatch(row -> "OUTBOUND".equals(row.getValueString("direction")));
assertThat(SystemWrites.call(() -> EdiRecords.query("edi_outbound_message", new QQueryFilter()))).anyMatch(row -> "DEAD_LETTER".equals(row.getValueString("status")));
assertThat(SystemWrites.call(() -> EdiRecords.query("edi_functional_group", new QQueryFilter()))).anyMatch(row -> row.getValueInstant("ackReceivedAt") != null);
assertThat(SystemWrites.call(() -> EdiRecords.query("edi_exception", new QQueryFilter()))).anyMatch(row -> "OVERDUE_ACK".equals(row.getValueString("type")));
var partners = SystemWrites.call(() -> EdiRecords.query("edi_partner", new QQueryFilter()));
assertThat(partners).extracting(row -> row.getValueString("code")).containsExactlyInAnyOrder("NORTHWIND", "HARBOR", "SUMMIT", "RIDGELINE");
assertThat(partners).extracting(row -> row.getValueString("code")).containsExactlyInAnyOrder("NORTHWIND", "HARBOR", "SUMMIT", "RIDGELINE", "PACIFIC");
assertThat(partners).allSatisfy(row -> assertThat(row.getValueString("contactEmail")).endsWith("@example.invalid"));
assertThat(SystemWrites.call(() -> EdiRecords.query("sample_wms_order", new QQueryFilter()))).hasSize(3);
assertThat(SystemWrites.call(() -> EdiRecords.query("sample_shipment_status", new QQueryFilter()))).hasSize(3);
@@ -103,7 +103,17 @@ class EdiDemoServerTest
assertThat(transactions).anyMatch(row -> "204".equals(row.getValueString("transactionSet")) && "SENT".equals(row.getValueString("status")));
assertThat(transactions).anyMatch(row -> "940".equals(row.getValueString("transactionSet")) && "INVALID".equals(row.getValueString("status")));
assertThat(transactions).anyMatch(row -> "214".equals(row.getValueString("transactionSet")) && "INVALID".equals(row.getValueString("status")));
var as2Inbox = SystemWrites.call(() -> EdiRecords.query("edi_inbox", EdiRecords.equal("transportType", "AS2")));
assertThat(as2Inbox).hasSize(3).allMatch(row -> row.getValueString("externalId").endsWith("@pacific"));
var receipts = SystemWrites.call(() -> EdiRecords.query("edi_as2_mdn", new QQueryFilter()));
assertThat(receipts).anyMatch(row -> "INBOUND".equals(row.getValueString("direction")) && Boolean.TRUE.equals(row.getValueBoolean("signatureValid")));
assertThat(receipts).filteredOn(row -> "OUTBOUND".equals(row.getValueString("direction"))).hasSize(3);
var as2Transports = SystemWrites.call(() -> EdiRecords.query("edi_transport", EdiRecords.equal("type", "AS2"))).stream().map(row -> row.getValue("id")).toList();
var as2Messages = SystemWrites.call(() -> EdiRecords.query("edi_outbound_message", new QQueryFilter())).stream().filter(row -> as2Transports.contains(row.getValue("transportId"))).toList();
assertThat(as2Messages).isNotEmpty().allMatch(row -> "DELIVERED".equals(row.getValueString("status")));
assertThat(as2Messages).anyMatch(row -> row.getValueString("transportMic") != null);
int port = demo.start(0);
assertThat(client.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/qqq/hooks/edi/as2/nobody")).POST(HttpRequest.BodyPublishers.ofString("x")).build(), HttpResponse.BodyHandlers.ofString()).statusCode()).isEqualTo(404);
assertThat(client.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/app/edi")).GET().build(), HttpResponse.BodyHandlers.ofString()).statusCode()).isEqualTo(200);
for(String table : java.util.List.of("edi_partner", "edi_transaction", "edi_exception"))
{