[
https://issues.apache.org/jira/browse/CAMEL-23065?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Reto Peter updated CAMEL-23065:
-------------------------------
Attachment: MicUtils.java
> MicUtils.createReceivedContentMic() computes wrong MIC for compressed
> messages (RFC 5402 violation)
> ---------------------------------------------------------------------------------------------------
>
> Key: CAMEL-23065
> URL: https://issues.apache.org/jira/browse/CAMEL-23065
> Project: Camel
> Issue Type: Bug
> Components: camel-as2
> Affects Versions: 4.18.0
> Reporter: Reto Peter
> Priority: Major
> Attachments: MicUtils.java
>
>
> {panel:title=Problem}
> \{{MicUtils.createReceivedContentMic()}} in \{{camel-as2-api}} always
> computes the MIC (Message Integrity Check) over the fully decompressed
> content. Per RFC 5402 (AS2 Compression), the MIC must be computed over "what
> was signed" — which depends on the
> compression ordering. When compression is used, the MDN returned to the
> sender contains a wrong MIC, causing the sender to flag the transfer as
> compromised (MIC mismatch).
> \{panel}
> h3. RFC 5402 Background
> AS2 with compression supports two orderings:
> Compress-before-sign (compress → sign → encrypt):
> \{code}
> Enveloped (encrypted)
> └─ MultipartSigned
> └─ part 0: CompressedDataEntity ← MIC must be computed over THIS
> (compressed bytes)
> \{code}
> Sign-before-compress (sign → compress → encrypt):
> \{code}
> Enveloped (encrypted)
> └─ CompressedDataEntity
> └─ (decompress) → MultipartSigned
> └─ part 0: ApplicationEntity ← MIC must be
> computed over THIS (uncompressed bytes)
> \{code}
> RFC 5402 Section 4 states: "The MIC MUST be calculated over the same
> content that was signed."
> h3. Root Cause
> The current \{{createReceivedContentMic()}} method calls
> \{{HttpMessageUtils.extractEdiPayload()}} which fully decompresses the
> content, then hashes whatever comes out via
> \{{EntityUtils.getContent(entity)}}:
> \{code:java|title=Current code in MicUtils.java (Camel 4.18.0)}
> public static ReceivedContentMic createReceivedContentMic(
> ClassicHttpRequest request, Certificate[]
> validateSigningCertificateChain,
> PrivateKey decryptingPrivateKey) throws HttpException {
> // ... disposition notification options parsing ...
> HttpEntity entity = HttpMessageUtils.extractEdiPayload(request,
> new HttpMessageUtils.DecrpytingAndSigningInfo(
> validateSigningCertificateChain, decryptingPrivateKey));
> byte[] content = EntityUtils.getContent(entity); // <-- BUG: always uses
> decompressed content
> String micAS2AlgorithmName =
> AS2MicAlgorithm.getAS2AlgorithmName(micJdkAlgorithmName);
> byte[] mic = createMic(content, micJdkAlgorithmName);
> // ...
> }
> \{code}
> \{{extractEdiPayload()}} processes the full entity hierarchy (decrypt →
> decompress → verify signature → extract payload). The returned entity is
> always the final decompressed payload. This means:
> - Compress-before-sign: The sender signed the compressed entity, so the MIC
> should be over the compressed bytes. But Camel hashes the decompressed bytes
> → MIC mismatch.
> - Sign-before-compress: The sender signed the original uncompressed entity,
> and Camel hashes the decompressed (= original) bytes → MIC matches (only by
> coincidence).
> h3. Steps to Reproduce
> Configure a Camel AS2 server endpoint with encryption, signing, and
> compression enabled
> Have a partner send an AS2 message using compress-before-sign ordering
> (this is the default ordering in most AS2 implementations, including
> Mendelson)
> Observe: MDN is returned, but the \{{Received-Content-MIC}} value in the
> MDN does not match the sender's expected MIC
> The sender reports a MIC verification failure / integrity error
> h3. Proposed Fix
> After extracting the EDI payload (which triggers entity parsing), navigate
> the parsed entity hierarchy on the request to find the entity at the "signed"
> level, then compute the MIC over the correct entity.
> h4. New helper record and method to add
> \{code:java|title=New helper: findSignedDataEntity()}
> private record SignedDataResult(MimeEntity entity, boolean
> compressionDetected) {}
> /**
> - Navigate the parsed entity hierarchy to find the entity at the "signed"
> level.
> - Handles both compression orderings defined by RFC 5402.
> */
> private static SignedDataResult findSignedDataEntity(ClassicHttpRequest
> request, PrivateKey decryptKey) {
> try {
> if (!(request instanceof HttpEntityContainer)) {
> return null;
> }
> HttpEntity outerEntity = ((HttpEntityContainer) request).getEntity();
> // If encrypted, decrypt to get the inner entity
> MimeEntity innerEntity;
> if (outerEntity instanceof ApplicationPkcs7MimeEnvelopedDataEntity
> envelopedEntity) {
> if (decryptKey == null) {
> return null;
> }
> innerEntity = envelopedEntity.getEncryptedEntity(decryptKey);
> } else if (outerEntity instanceof MimeEntity) {
> innerEntity = (MimeEntity) outerEntity;
> } else {
> return null;
> }
> // Case 1: Compress-before-sign (compress → sign → encrypt)
> // After decryption: MultipartSigned → part 0 is CompressedDataEntity
> if (innerEntity instanceof MultipartSignedEntity signedEntity) {
> MimeEntity signedData = signedEntity.getSignedDataEntity();
> boolean compressed = signedData instanceof
> ApplicationPkcs7MimeCompressedDataEntity;
> return new SignedDataResult(signedData, compressed);
> }
> // Case 2: Sign-before-compress (sign → compress → encrypt)
> // After decryption: CompressedDataEntity → decompress → MultipartSigned →
> part 0
> if (innerEntity instanceof ApplicationPkcs7MimeCompressedDataEntity
> compressedEntity) {
> MimeEntity decompressed = compressedEntity.getCompressedEntity(new
> ZlibExpanderProvider());
> if (decompressed instanceof MultipartSignedEntity signedEntity) {
> return new SignedDataResult(signedEntity.getSignedDataEntity(),
> true);
> }
> return new SignedDataResult(innerEntity, true);
> }
> // Plain or other structure — no compression
> return new SignedDataResult(innerEntity, false);
> } catch (Exception e) {
> LOG.warn("Failed to navigate entity hierarchy for MIC computation: {}",
> e.getMessage());
> return null;
> }
> }
> \{code}
> h4. Modified \{{createReceivedContentMic()}}
> Replace the single \{{EntityUtils.getContent(entity)}} call with
> compression-aware logic:
> \{code:diff}
> HttpEntity entity = HttpMessageUtils.extractEdiPayload(request,
> new HttpMessageUtils.DecrpytingAndSigningInfo(
> validateSigningCertificateChain, decryptingPrivateKey));
> - byte[] content = EntityUtils.getContent(entity);
> - byte[] content;
> - // RFC 5402: MIC is computed over "what was signed":
> - // - Compress-before-sign: signed data IS the compressed entity → hash
> compressed entity
> - // - Sign-before-compress: signed data is the original uncompressed
> entity → hash content
> - SignedDataResult result = findSignedDataEntity(request,
> decryptingPrivateKey);
> - MimeEntity signedDataEntity = result != null ? result.entity : null;
> - if (signedDataEntity instanceof ApplicationPkcs7MimeCompressedDataEntity)
> {
> // Compress-before-sign: hash the compressed MIME entity (what was
> signed)
> ByteArrayOutputStream compressedBaos = new ByteArrayOutputStream();
> try {
> signedDataEntity.writeTo(compressedBaos);
> content = compressedBaos.toByteArray();
> } catch (Exception e) {
> LOG.warn("Failed to get compressed entity bytes, falling back to
> decompressed content: {}",
> e.getMessage());
> content = EntityUtils.getContent(entity);
> }
> - } else {
> // Sign-before-compress OR no compression — decompressed content is
> correct
> content = EntityUtils.getContent(entity);
> - }
> - String micAS2AlgorithmName =
> AS2MicAlgorithm.getAS2AlgorithmName(micJdkAlgorithmName);
> \{code}
> h4. Additional imports needed
> \{code:java}
> import java.io.ByteArrayOutputStream;
> import
> org.apache.camel.component.as2.api.entity.ApplicationPkcs7MimeCompressedDataEntity;
> import
> org.apache.camel.component.as2.api.entity.ApplicationPkcs7MimeEnvelopedDataEntity;
> import org.apache.camel.component.as2.api.entity.MimeEntity;
> import org.apache.camel.component.as2.api.entity.MultipartSignedEntity;
> import org.apache.hc.core5.http.HttpEntityContainer;
> import org.bouncycastle.cms.jcajce.ZlibExpanderProvider;
> \{code}
> h3. Why This Fix is Correct
> The key insight is that \{{extractEdiPayload()}} already parses the full
> entity hierarchy and attaches the parsed entities to the request object.
> After that call, we can navigate the already-parsed hierarchy (no re-parsing
> needed) to find which entity was
> signed:
> - Compress-before-sign: After decryption we get \{{MultipartSigned}} → its
> \{{getSignedDataEntity()}} returns the \{{CompressedDataEntity}} → we hash
> its \{{writeTo()}} output (the compressed MIME bytes, including headers)
> - Sign-before-compress: After decryption we get \{{CompressedDataEntity}} →
> decompress → \{{MultipartSigned}} → its \{{getSignedDataEntity()}} returns
> the original \{{ApplicationEntity}} → we hash via
> \{{EntityUtils.getContent()}} (same as before)
> - No compression: Falls through to the existing
> \{{EntityUtils.getContent()}} path (unchanged behavior)
> h3. Test Scenario
> Tested with:
> - Mendelson AS2 (SYNC MDN) — uses compress-before-sign by default
> - OpenAS2 (ASYNC MDN) — tested both compression orderings
> Before fix: MIC mismatch reported by both partners when compression was
> enabled.
> After fix: MIC matches correctly for both compression orderings
--
This message was sent by Atlassian Jira
(v8.20.10#820010)