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)