Reto Peter created CAMEL-23065:
----------------------------------

             Summary: 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


{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)

Reply via email to