This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.22.x by this push:
     new 57ff24328e9c [backport camel-4.22.x] CAMEL-26425: camel-as2 - verify 
the signature and compute the MIC over the signed part as received (#28768)
57ff24328e9c is described below

commit 57ff24328e9c415b24371d2dc6e43a6929c7728a
Author: Guillaume Nodet <[email protected]>
AuthorDate: Mon Nov 2 15:19:48 2026 +0100

    [backport camel-4.22.x] CAMEL-26425: camel-as2 - verify the signature and 
compute the MIC over the signed part as received (#28768)
---
 .../component/as2/api/entity/EntityParser.java     |  38 ++-
 .../as2/api/entity/MultipartSignedEntity.java      |  41 +++
 .../as2/api/io/AS2SessionInputBuffer.java          |  67 ++++
 .../camel/component/as2/api/util/MicUtils.java     |  52 +--
 .../camel/component/as2/api/util/SigningUtils.java |  19 +-
 .../api/entity/EntityParserBodyPartTextTest.java   |  90 ++++++
 .../MultipartSignedEntityAsReceivedTest.java       | 357 +++++++++++++++++++++
 .../as2/api/io/AS2SessionInputBufferTest.java      |  94 ++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc    |  16 +
 9 files changed, 740 insertions(+), 34 deletions(-)

diff --git 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/entity/EntityParser.java
 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/entity/EntityParser.java
index 7d2b25b6de84..fdff471d9aac 100644
--- 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/entity/EntityParser.java
+++ 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/entity/EntityParser.java
@@ -163,12 +163,19 @@ public final class EntityParser {
         try {
             foundEndBoundary = false;
             CharArrayBuffer lineBuffer = new CharArrayBuffer(1024);
+            // the line read last - e.g. the close delimiter of a nested 
multipart - may be the one right before the
+            // delimiter looked for: its line break belongs to that delimiter 
then
+            int previousLineTerminatorLength = lineTerminatorLength(inbuffer);
             while (inbuffer.readLine(lineBuffer, is) != -1) {
                 final ParserCursor cursor = new ParserCursor(0, 
lineBuffer.length());
                 if (isBoundaryDelimiter(lineBuffer, cursor, boundary)) {
+                    // the delimiter line and the line break before it belong 
to the boundary, not to a part
+                    // being captured as received (RFC 2046, 5.1.1)
+                    
inbuffer.excludeLastLineFromRawCapture(previousLineTerminatorLength);
                     foundEndBoundary = true;
                     break;
                 }
+                previousLineTerminatorLength = lineTerminatorLength(inbuffer);
                 lineBuffer.clear();
             }
         } catch (Exception e) {
@@ -540,6 +547,7 @@ public final class EntityParser {
             String contentTransferEncoding)
             throws ParseException {
         CharsetDecoder previousDecoder = inbuffer.getCharsetDecoder();
+        boolean rawCaptureOpen = false;
 
         try {
 
@@ -568,6 +576,12 @@ public final class EntityParser {
             // Parse Signed Entity Part
             //
 
+            // Keep the part exactly as received: the signature was created 
over these bytes (RFC 1847, 2.1) and
+            // the MIC is computed over them (RFC 4130, 7.1) - the parsed part 
re-serialised is not the same when
+            // the sender used base64 or quoted-printable, or formats headers 
differently.
+            inbuffer.startRawCapture();
+            rawCaptureOpen = true;
+
             // Read Text Report Body Part Headers
             Header[] headers = AbstractMessageParser.parseHeaders(inbuffer, 
is, -1, -1, BasicLineParser.INSTANCE,
                     new ArrayList<>());
@@ -588,6 +602,10 @@ public final class EntityParser {
 
             MimeEntity signedEntity = parseEntityBody(inbuffer, is, boundary, 
signedEntityContentType,
                     signedEntityContentTransferEncoding, "", headers);
+            // the part's parser consumed the boundary delimiter line that 
ends the part and left it, with the line
+            // break before it, out of the capture (RFC 2046, 5.1.1) - the 
same line break it removed from the text
+            rawCaptureOpen = false;
+            multipartSignedEntity.setSignedDataRaw(inbuffer.stopRawCapture());
             signedEntity.removeAllHeaders();
             signedEntity.setHeaders(headers);
             multipartSignedEntity.addPart(signedEntity);
@@ -637,6 +655,9 @@ public final class EntityParser {
             parseException.initCause(e);
             throw parseException;
         } finally {
+            if (rawCaptureOpen) {
+                inbuffer.stopRawCapture();
+            }
             inbuffer.setCharsetDecoder(previousDecoder);
         }
     }
@@ -1049,6 +1070,7 @@ public final class EntityParser {
             throws IOException {
         CharArrayBuffer buffer = new CharArrayBuffer(DEFAULT_BUFFER_SIZE);
         CharArrayBuffer line = new CharArrayBuffer(DEFAULT_BUFFER_SIZE);
+        int previousLineTerminatorLength = 0;
         while (true) {
             final int l = inbuffer.readLine(line, is);
             if (l == -1) {
@@ -1056,9 +1078,12 @@ public final class EntityParser {
             }
 
             if (boundary != null && isBoundaryDelimiter(line, null, boundary)) 
{
-                // remove last CRLF from buffer which belongs to boundary
-                int length = buffer.length();
-                buffer.setLength(length - 2);
+                // the line break before the delimiter line belongs to the 
boundary, not to the part (RFC 2046,
+                // 5.1.1): remove exactly the terminator the previous line had 
- CRLF, a bare LF, nothing for an
+                // empty part - from the text, and leave the same bytes and 
the delimiter line out of a capture of
+                // the part as received, so that the bytes verified and the 
text parsed are the same part
+                buffer.setLength(buffer.length() - 
previousLineTerminatorLength);
+                
inbuffer.excludeLastLineFromRawCapture(previousLineTerminatorLength);
                 break;
             }
 
@@ -1069,12 +1094,19 @@ public final class EntityParser {
             if (inbuffer.isLastLineReadTerminatedByLineFeed()) {
                 buffer.append((char) LF);
             }
+            previousLineTerminatorLength = lineTerminatorLength(inbuffer);
             line.clear();
         }
 
         return buffer.toString();
     }
 
+    /** The bytes of the line break that ended the line {@code inbuffer} read 
last: 2 for CRLF, 1 for LF, else 0. */
+    private static int lineTerminatorLength(AS2SessionInputBuffer inbuffer) {
+        return (inbuffer.isLastLineReadEnrichedByCarriageReturn() ? 1 : 0)
+               + (inbuffer.isLastLineReadTerminatedByLineFeed() ? 1 : 0);
+    }
+
     public static List<CharArrayBuffer> parseBodyPartFields(
             final AS2SessionInputBuffer inbuffer,
             final InputStream is,
diff --git 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/entity/MultipartSignedEntity.java
 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/entity/MultipartSignedEntity.java
index 21b361dc9d31..a9b60fd15871 100644
--- 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/entity/MultipartSignedEntity.java
+++ 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/entity/MultipartSignedEntity.java
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.component.as2.api.entity;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 
 import org.apache.camel.component.as2.api.AS2SignedDataGenerator;
@@ -25,6 +26,8 @@ import org.apache.hc.core5.http.HttpException;
 
 public class MultipartSignedEntity extends MultipartMimeEntity {
 
+    private byte[] signedDataRaw;
+
     public MultipartSignedEntity(MimeEntity data, AS2SignedDataGenerator 
signer, String signatureCharSet,
                                  String signatureTransferEncoding, boolean 
isMainBody, String boundary) throws HttpException {
         super(signer, isMainBody, (boundary == null) ? 
EntityUtils.createBoundaryValue() : boundary);
@@ -41,6 +44,44 @@ public class MultipartSignedEntity extends 
MultipartMimeEntity {
         this.isMainBody = isMainBody;
     }
 
+    /**
+     * The first body part exactly as received - its MIME headers and its body 
in the transfer encoding the sender used
+     * - which is what the signature was created over (RFC 1847, 2.1) and what 
the MIC is computed over (RFC 4130, 7.1).
+     * Set by the parser; null for an entity built locally.
+     *
+     * @return a copy of the bytes, or null
+     */
+    public byte[] getSignedDataRaw() {
+        return signedDataRaw == null ? null : signedDataRaw.clone();
+    }
+
+    /** Set by {@link EntityParser} only: the bytes a signature is checked 
against must not change after parsing. */
+    void setSignedDataRaw(byte[] signedDataRaw) {
+        this.signedDataRaw = signedDataRaw;
+    }
+
+    /**
+     * The bytes a signature over this entity has to be verified against: the 
first body part as received when this
+     * entity was parsed, otherwise the first body part serialised. 
Re-serialising a received part would re-encode it
+     * (base64 line length, quoted-printable soft breaks, header formatting) 
and change the signed bytes.
+     *
+     * @return the bytes, or null when there is no first body part; a part as 
received is returned as held, not copied -
+     *         to be read, never modified
+     */
+    public byte[] getSignedDataContent() throws IOException {
+        if (signedDataRaw != null) {
+            return signedDataRaw;
+        }
+        MimeEntity signedDataEntity = getSignedDataEntity();
+        if (signedDataEntity == null) {
+            return null;
+        }
+        try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+            signedDataEntity.writeTo(out);
+            return out.toByteArray();
+        }
+    }
+
     public MimeEntity getSignedDataEntity() {
         if (getPartCount() > 0) {
             return getPart(0);
diff --git 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/io/AS2SessionInputBuffer.java
 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/io/AS2SessionInputBuffer.java
index 589d95441fd1..ddb06b215910 100644
--- 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/io/AS2SessionInputBuffer.java
+++ 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/io/AS2SessionInputBuffer.java
@@ -22,6 +22,9 @@ import java.nio.ByteBuffer;
 import java.nio.CharBuffer;
 import java.nio.charset.CharsetDecoder;
 import java.nio.charset.CoderResult;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Deque;
 
 import org.apache.camel.util.ObjectHelper;
 import org.apache.hc.core5.http.impl.BasicHttpTransportMetrics;
@@ -51,6 +54,14 @@ public class AS2SessionInputBuffer implements 
SessionInputBuffer {
 
     private boolean lastLineReadEnrichedByCarriageReturn;
     private boolean lastLineReadTerminatedByLineFeed;
+    // The bytes consumed by readLine since startRawCapture(), exactly as 
received (line terminators included), so
+    // that a parser can keep a body part as the sender wrote it - e.g. the 
signed part of a multipart/signed, over
+    // which a signature or MIC has to be verified (RFC 1847 2.1): 
re-serialising the parsed part is not the same.
+    // One buffer for all open captures, a mark (the buffer length at the 
start) per capture, innermost last.
+    private ByteArrayBuffer rawCapture;
+    private final Deque<Integer> rawCaptureMarks = new ArrayDeque<>();
+    private int lastLineRawLength;
+    private int rawCaptureTrailingBytesExcluded;
 
     public AS2SessionInputBuffer(final BasicHttpTransportMetrics metrics,
                                  final int buffersize,
@@ -179,6 +190,8 @@ public class AS2SessionInputBuffer implements 
SessionInputBuffer {
         boolean retry = true;
         this.lastLineReadEnrichedByCarriageReturn = false;
         this.lastLineReadTerminatedByLineFeed = false;
+        this.lastLineRawLength = 0;
+        this.rawCaptureTrailingBytesExcluded = 0;
         while (retry) {
             // attempt to find end of line (LF)
             int pos = -1;
@@ -233,12 +246,65 @@ public class AS2SessionInputBuffer implements 
SessionInputBuffer {
         return lastLineReadEnrichedByCarriageReturn;
     }
 
+    /**
+     * Starts keeping the raw bytes every following {@link 
#readLine(CharArrayBuffer, InputStream)} consumes - line
+     * terminators included, no charset decoding - until the matching {@link 
#stopRawCapture()}. Captures nest: a
+     * capture started while another one is open lies within it, and every 
stop ends the innermost open capture only.
+     */
+    public void startRawCapture() {
+        if (this.rawCapture == null) {
+            this.rawCapture = new ByteArrayBuffer(this.buffer.length);
+        }
+        this.rawCaptureMarks.push(this.rawCapture.length());
+    }
+
+    /**
+     * Ends the innermost capture started by {@link #startRawCapture()}.
+     *
+     * @return the bytes {@code readLine} consumed since that capture started, 
less what
+     *         {@link #excludeLastLineFromRawCapture(int)} left out, or null 
when no capture is open
+     */
+    public byte[] stopRawCapture() {
+        if (this.rawCaptureMarks.isEmpty()) {
+            return null;
+        }
+        int from = this.rawCaptureMarks.pop();
+        int to = Math.max(from, this.rawCapture.length() - 
this.rawCaptureTrailingBytesExcluded);
+        this.rawCaptureTrailingBytesExcluded = 0;
+        byte[] captured = Arrays.copyOfRange(this.rawCapture.array(), from, 
to);
+        if (this.rawCaptureMarks.isEmpty()) {
+            this.rawCapture = null;
+        }
+        return captured;
+    }
+
+    /**
+     * Leaves the line the last {@link #readLine(CharArrayBuffer, 
InputStream)} consumed, and the given number of bytes
+     * before it, out of the capture that the next {@link #stopRawCapture()} 
ends. For a parser that has just read the
+     * boundary delimiter line ending a body part: that line and the line 
break before it belong to the boundary, not to
+     * the part (RFC 2046, 5.1.1). Forgotten by the next {@code readLine}.
+     *
+     * @param precedingBytes the bytes before the last line to leave out as 
well - the line break that ended the line
+     *                       before, as that line reported it
+     */
+    public void excludeLastLineFromRawCapture(int precedingBytes) {
+        this.rawCaptureTrailingBytesExcluded = this.lastLineRawLength + 
Math.max(0, precedingBytes);
+    }
+
+    private void captureRaw(byte[] bytes, int off, int len) {
+        this.lastLineRawLength = len;
+        if (this.rawCapture != null && len > 0) {
+            this.rawCapture.append(bytes, off, len);
+        }
+    }
+
     @Override
     public HttpTransportMetrics getMetrics() {
         return this.metrics;
     }
 
     private int lineFromLineBuffer(final CharArrayBuffer charbuffer) throws 
IOException {
+        captureRaw(this.linebuffer.array(), 0, this.linebuffer.length());
         // discard LF if found
         int len = this.linebuffer.length();
         if (len > 0) {
@@ -269,6 +335,7 @@ public class AS2SessionInputBuffer implements 
SessionInputBuffer {
         final int off = this.bufferpos;
         int len;
         this.bufferpos = pos + 1;
+        captureRaw(this.buffer, off, pos + 1 - off);
         if (pos > off && this.buffer[pos - 1] == CR) {
             // skip CR if found
             pos--;
diff --git 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/util/MicUtils.java
 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/util/MicUtils.java
index 5e11779f6ca4..9019c8da348b 100644
--- 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/util/MicUtils.java
+++ 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/util/MicUtils.java
@@ -16,6 +16,7 @@
  */
 package org.apache.camel.component.as2.api.util;
 
+import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
@@ -35,7 +36,6 @@ import org.apache.camel.component.as2.api.entity.MimeEntity;
 import org.apache.camel.component.as2.api.entity.MultipartSignedEntity;
 import org.apache.hc.core5.http.ClassicHttpRequest;
 import org.apache.hc.core5.http.ContentType;
-import org.apache.hc.core5.http.HttpEntity;
 import org.apache.hc.core5.http.HttpException;
 import org.bouncycastle.cms.jcajce.ZlibExpanderProvider;
 import org.slf4j.Logger;
@@ -109,9 +109,9 @@ public final class MicUtils {
         // Compute MIC over the correct content per RFC 5402:
         // - For compress-before-sign: MIC covers the compressed entity (what 
was signed)
         // - For sign-before-compress or no compression: MIC covers the EDI 
payload
-        HttpEntity micEntity = findMicEntity(request, 
validateSigningCertificateChain, decryptingPrivateKey);
-
-        byte[] content = EntityUtils.getContent(micEntity);
+        // For a signed message that is the signed part as received (RFC 4130, 
7.1) - the bytes the sender's own
+        // MIC covers - never a re-serialisation of the parsed part.
+        byte[] content = findMicContent(request, 
validateSigningCertificateChain, decryptingPrivateKey);
 
         String micAS2AlgorithmName = 
AS2MicAlgorithm.getAS2AlgorithmName(micJdkAlgorithmName);
         byte[] mic = createMic(content, micJdkAlgorithmName);
@@ -123,12 +123,13 @@ public final class MicUtils {
     }
 
     /**
-     * Finds the correct entity to compute the MIC over, per RFC 5402.
+     * Finds the correct content to compute the MIC over, per RFC 5402.
      * <p>
      * For compress-before-sign, the MIC must cover the compressed entity 
(what was signed). For sign-before-compress or
-     * no compression, the MIC covers the EDI payload.
+     * no compression, the MIC covers the EDI payload. For a signed message 
the content is the signed part exactly as
+     * received.
      */
-    private static HttpEntity findMicEntity(
+    private static byte[] findMicContent(
             ClassicHttpRequest request, Certificate[] 
validateSigningCertificateChain, PrivateKey decryptingPrivateKey)
             throws HttpException {
 
@@ -140,34 +141,37 @@ public final class MicUtils {
         }
         ContentType contentType = ContentType.parse(contentTypeString);
 
-        // Navigate the message structure to find the signed data entity
-        HttpEntity entity = findSignedDataEntity(request, contentType, 
decryptingPrivateKey);
-        if (entity != null) {
-            return entity;
+        // Navigate the message structure to find the multipart/signed entity
+        MultipartSignedEntity multipartSignedEntity = 
findMultipartSignedEntity(request, contentType, decryptingPrivateKey);
+        if (multipartSignedEntity != null) {
+            try {
+                byte[] signedContent = 
multipartSignedEntity.getSignedDataContent();
+                if (signedContent != null) {
+                    return signedContent;
+                }
+            } catch (IOException e) {
+                throw new HttpException("Failed to create MIC: cannot read the 
signed content", e);
+            }
         }
 
         // No multipart/signed found, fall back to extracting the EDI payload
-        return HttpMessageUtils.extractEdiPayload(request,
-                new 
HttpMessageUtils.DecrpytingAndSigningInfo(validateSigningCertificateChain, 
decryptingPrivateKey));
+        return 
EntityUtils.getContent(HttpMessageUtils.extractEdiPayload(request,
+                new 
HttpMessageUtils.DecrpytingAndSigningInfo(validateSigningCertificateChain, 
decryptingPrivateKey)));
     }
 
     /**
-     * Navigate the entity hierarchy to find the signed data entity (part 0 of 
multipart/signed). Returns the signed
-     * data entity which is what the MIC should be computed over, or null if 
no multipart/signed is found.
+     * Navigate the entity hierarchy to find the multipart/signed entity, 
whose signed part (part 0, as received) is
+     * what the MIC is computed over. Returns null if no multipart/signed is 
found.
      */
-    private static HttpEntity findSignedDataEntity(
+    private static MultipartSignedEntity findMultipartSignedEntity(
             ClassicHttpRequest request, ContentType contentType, PrivateKey 
decryptingPrivateKey)
             throws HttpException {
 
         String mimeType = contentType.getMimeType().toLowerCase();
 
         if (AS2MimeType.MULTIPART_SIGNED.equals(mimeType)) {
-            // Top-level signed: MIC is over the signed data entity (part 0)
-            MultipartSignedEntity multipartSignedEntity
-                    = HttpMessageUtils.getEntity(request, 
MultipartSignedEntity.class);
-            if (multipartSignedEntity != null) {
-                return multipartSignedEntity.getSignedDataEntity();
-            }
+            // Top-level signed
+            return HttpMessageUtils.getEntity(request, 
MultipartSignedEntity.class);
         } else if (AS2MimeType.APPLICATION_PKCS7_MIME.equals(mimeType)) {
             String smimeType = contentType.getParameter("smime-type");
             if ("compressed-data".equals(smimeType)) {
@@ -178,7 +182,7 @@ public final class MicUtils {
                     MimeEntity inner = compressedEntity
                             .getCompressedEntity(new ZlibExpanderProvider());
                     if (inner instanceof MultipartSignedEntity signedEntity) {
-                        return signedEntity.getSignedDataEntity();
+                        return signedEntity;
                     }
                 }
             } else if ("enveloped-data".equals(smimeType) && 
decryptingPrivateKey != null) {
@@ -193,7 +197,7 @@ public final class MicUtils {
                         String decryptedMime = 
decryptedCt.getMimeType().toLowerCase();
                         if (AS2MimeType.MULTIPART_SIGNED.equals(decryptedMime)
                                 && decryptedEntity instanceof 
MultipartSignedEntity signedEntity) {
-                            return signedEntity.getSignedDataEntity();
+                            return signedEntity;
                         }
                     }
                 }
diff --git 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/util/SigningUtils.java
 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/util/SigningUtils.java
index 6b3d585960f4..71c4fdef0e3d 100644
--- 
a/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/util/SigningUtils.java
+++ 
b/components/camel-as2/camel-as2-api/src/main/java/org/apache/camel/component/as2/api/util/SigningUtils.java
@@ -17,7 +17,6 @@
 package org.apache.camel.component.as2.api.util;
 
 import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.security.PrivateKey;
 import java.security.cert.Certificate;
@@ -28,7 +27,6 @@ import java.util.Arrays;
 import org.apache.camel.component.as2.api.AS2SignatureAlgorithm;
 import org.apache.camel.component.as2.api.AS2SignedDataGenerator;
 import 
org.apache.camel.component.as2.api.entity.ApplicationPkcs7SignatureEntity;
-import org.apache.camel.component.as2.api.entity.MimeEntity;
 import org.apache.camel.component.as2.api.entity.MultipartSignedEntity;
 import org.apache.camel.util.ObjectHelper;
 import org.apache.hc.core5.http.HttpException;
@@ -141,16 +139,23 @@ public final class SigningUtils {
         return false;
     }
 
+    /**
+     * Verifies the signature of a multipart/signed entity against the given 
certificates - over the signed part as it
+     * was received when the entity was parsed (RFC 1847, 2.1), not over a 
re-serialisation of the parsed part, which
+     * differs from the sender's bytes for base64 or quoted-printable encoded 
parts.
+     */
     public static boolean isValid(MultipartSignedEntity multipartSignedEntity, 
Certificate[] signingCertificateChain) {
-        MimeEntity signedEntity = multipartSignedEntity.getSignedDataEntity();
         ApplicationPkcs7SignatureEntity applicationPkcs7SignatureEntity = 
multipartSignedEntity.getSignatureEntity();
-        if (signedEntity == null || applicationPkcs7SignatureEntity == null) {
+        if (applicationPkcs7SignatureEntity == null) {
             return false;
         }
 
-        try (ByteArrayOutputStream o = new ByteArrayOutputStream()) {
-            signedEntity.writeTo(o);
-            return isValidSigned(o.toByteArray(), 
applicationPkcs7SignatureEntity.getSignature(), signingCertificateChain);
+        try {
+            byte[] signedContent = 
multipartSignedEntity.getSignedDataContent();
+            if (signedContent == null) {
+                return false;
+            }
+            return isValidSigned(signedContent, 
applicationPkcs7SignatureEntity.getSignature(), signingCertificateChain);
         } catch (IOException e) {
             return false;
         }
diff --git 
a/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/EntityParserBodyPartTextTest.java
 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/EntityParserBodyPartTextTest.java
new file mode 100644
index 000000000000..1253da794c73
--- /dev/null
+++ 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/EntityParserBodyPartTextTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://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 org.apache.camel.component.as2.api.entity;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.camel.component.as2.api.io.AS2SessionInputBuffer;
+import org.apache.hc.core5.http.impl.BasicHttpTransportMetrics;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The text of a body part ends where the boundary delimiter begins: the line 
break before the delimiter line belongs to
+ * the boundary (RFC 2046, 5.1.1), whether it is CRLF or - as openssl writes 
it without -crlfeol - a bare LF. A capture
+ * of the part as received ends at the same byte, so that what a signature is 
verified over and what is parsed are the
+ * same part.
+ */
+class EntityParserBodyPartTextTest {
+
+    private static final String BOUNDARY = "boundary-7";
+
+    private static AS2SessionInputBuffer buffer() {
+        return new AS2SessionInputBuffer(new BasicHttpTransportMetrics(), 
1024);
+    }
+
+    private static InputStream stream(String content) {
+        return new 
ByteArrayInputStream(content.getBytes(StandardCharsets.US_ASCII));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "\r\n", "\n" })
+    void onlyTheLineBreakBeforeTheDelimiterIsRemoved(String lineEnding) throws 
Exception {
+        // the part ends in a quote, which must survive - with a bare LF 
before the delimiter as well
+        String part = "UNA:+.? 'UNB+UNOC:3'" + lineEnding + "UNZ+1+7'";
+        String text = EntityParser.parseBodyPartText(buffer(), stream(part + 
lineEnding + "--" + BOUNDARY + lineEnding),
+                BOUNDARY);
+
+        assertEquals(part, text);
+    }
+
+    @Test
+    void anEmptyPartIsEmpty() throws Exception {
+        String text = EntityParser.parseBodyPartText(buffer(), stream("--" + 
BOUNDARY + "\r\n"), BOUNDARY);
+
+        assertEquals("", text);
+    }
+
+    @Test
+    void aPartWithoutDelimiterKeepsItsLastLineBreak() throws Exception {
+        String text = EntityParser.parseBodyPartText(buffer(), stream("last 
line\r\n"), BOUNDARY);
+
+        assertEquals("last line\r\n", text);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "\r\n", "\n" })
+    void theCaptureOfThePartAsReceivedEndsWhereTheTextEnds(String lineEnding) 
throws Exception {
+        String part = "first" + lineEnding + "second";
+        AS2SessionInputBuffer inbuffer = buffer();
+        InputStream is = stream(part + lineEnding + "--" + BOUNDARY + 
lineEnding + "next part");
+
+        inbuffer.startRawCapture();
+        String text = EntityParser.parseBodyPartText(inbuffer, is, BOUNDARY);
+        byte[] captured = inbuffer.stopRawCapture();
+
+        assertEquals(part, text);
+        assertArrayEquals(part.getBytes(StandardCharsets.US_ASCII), captured,
+                "the bytes a signature is verified over are the part the text 
was parsed from");
+    }
+}
diff --git 
a/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/MultipartSignedEntityAsReceivedTest.java
 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/MultipartSignedEntityAsReceivedTest.java
new file mode 100644
index 000000000000..b3aad0acfb02
--- /dev/null
+++ 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/MultipartSignedEntityAsReceivedTest.java
@@ -0,0 +1,357 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ *      http://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 org.apache.camel.component.as2.api.entity;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.Base64;
+
+import org.apache.camel.component.as2.api.AS2Header;
+import org.apache.camel.component.as2.api.AS2SignatureAlgorithm;
+import org.apache.camel.component.as2.api.AS2SignedDataGenerator;
+import org.apache.camel.component.as2.api.Utils;
+import org.apache.camel.component.as2.api.util.MicUtils;
+import org.apache.camel.component.as2.api.util.SigningUtils;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.io.entity.BasicHttpEntity;
+import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
+import org.bouncycastle.cms.CMSAlgorithm;
+import org.bouncycastle.cms.CMSEnvelopedDataGenerator;
+import org.bouncycastle.cms.CMSProcessableByteArray;
+import org.bouncycastle.cms.jcajce.JceCMSContentEncryptorBuilder;
+import org.bouncycastle.cms.jcajce.JceKeyTransRecipientInfoGenerator;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A signature over a multipart/signed is created over the first body part 
exactly as it is transmitted - its MIME
+ * headers and its body in the transfer encoding the sender applied (RFC 1847, 
2.1) - and the MIC of the receipt is
+ * computed over the same bytes (RFC 4130, 7.1). Another AS2 implementation 
(openssl, Lobster, OpenAS2, ...) encodes
+ * base64 or quoted-printable in its own way, e.g. with its own line length, 
so the part can only be verified over the
+ * bytes received, never over a re-serialisation of the parsed part. These 
tests play such an implementation.
+ */
+class MultipartSignedEntityAsReceivedTest {
+
+    private static final String EDI_MESSAGE = "UNA:+.? 
'UNB+UNOC:3+4000000000001:14+4000000000002:14+261028:1200+7'"
+                                              + 
"UNH+1+ORDERS:D:96A:UN'BGM+220+ORDER-7+9'"
+                                              + "NAD+BY+++Mueller Strasse 5 
Koeln'UNT+4+1'UNZ+1+7'";
+    private static final String BOUNDARY = "----=_Part_as_received_7";
+    private static final String MULTIPART_SIGNED_CONTENT_TYPE
+            = "multipart/signed; protocol=\"application/pkcs7-signature\"; 
micalg=sha-256; boundary=\"" + BOUNDARY + "\"";
+    private static final String DISPOSITION_NOTIFICATION_OPTIONS
+            = "signed-receipt-protocol=optional, pkcs7-signature; 
signed-receipt-micalg=optional, sha256";
+
+    private static KeyPair signingKP;
+    private static X509Certificate signingCert;
+    private static X509Certificate otherCert;
+
+    @BeforeAll
+    static void setUpKeysAndCertificates() throws Exception {
+        Security.addProvider(new BouncyCastleProvider());
+        KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", "BC");
+        kpg.initialize(2048, new SecureRandom());
+
+        String signingDN = "O=AS2 Partner, C=DE";
+        signingKP = kpg.generateKeyPair();
+        signingCert = Utils.makeCertificate(signingKP, signingDN, signingKP, 
signingDN);
+
+        String otherDN = "O=Somebody Else, C=DE";
+        KeyPair otherKP = kpg.generateKeyPair();
+        otherCert = Utils.makeCertificate(otherKP, otherDN, otherKP, otherDN);
+    }
+
+    /**
+     * The part as the sender transmitted it: base64 wrapped at 64 characters 
(openssl's width; camel-as2 re-encodes at
+     * 76), quoted-printable that escapes the apostrophes (allowed, decodes to 
the same payload; camel-as2 would
+     * re-encode without), or binary.
+     */
+    private static byte[] signedPart(String transferEncoding) {
+        String body;
+        switch (transferEncoding) {
+            case "base64":
+                body = 
wrap(Base64.getEncoder().encodeToString(EDI_MESSAGE.getBytes(StandardCharsets.UTF_8)),
 64);
+                break;
+            case "quoted-printable":
+                body = EDI_MESSAGE.replace("'", "=27");
+                break;
+            default:
+                body = EDI_MESSAGE;
+        }
+        String part = "Content-Type: application/edifact\r\n"
+                      + "Content-Transfer-Encoding: " + transferEncoding + 
"\r\n"
+                      + "\r\n"
+                      + body;
+        return part.getBytes(StandardCharsets.UTF_8);
+    }
+
+    /** The decoded payload of a parsed EDI entity, whatever form the entity 
holds it in. */
+    private static String payloadOf(ApplicationEntity ediEntity) throws 
Exception {
+        Object ediMessage = ediEntity.getEdiMessage();
+        if (ediMessage instanceof String text) {
+            return text;
+        }
+        if (ediMessage instanceof byte[] bytes) {
+            return new String(bytes, StandardCharsets.UTF_8);
+        }
+        if (ediMessage instanceof InputStream stream) {
+            return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        return String.valueOf(ediMessage);
+    }
+
+    private static String wrap(String s, int width) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < s.length(); i += width) {
+            if (i > 0) {
+                sb.append("\r\n");
+            }
+            sb.append(s, i, Math.min(s.length(), i + width));
+        }
+        return sb.toString();
+    }
+
+    /** A detached PKCS#7 signature over exactly the given bytes, as openssl 
cms -sign produces it. */
+    private static byte[] sign(byte[] signedPart) throws Exception {
+        AS2SignedDataGenerator generator = 
SigningUtils.createSigningGenerator(AS2SignatureAlgorithm.SHA256WITHRSA,
+                new Certificate[] { signingCert }, signingKP.getPrivate());
+        return generator.generate(new CMSProcessableByteArray(signedPart), 
false).getEncoded();
+    }
+
+    /** The body of a multipart/signed as transmitted: the part, then the 
signature, both ended by the boundary. */
+    private static byte[] multipartSignedBody(byte[] signedPart, byte[] 
signature) throws Exception {
+        return multipartSignedBody(signedPart, signature, "\r\n");
+    }
+
+    /**
+     * The same with the sender's line ending around the boundaries: CRLF as 
MIME asks, or a bare LF as openssl writes
+     * it unless told -crlfeol - the line break before a delimiter belongs to 
the boundary either way.
+     */
+    private static byte[] multipartSignedBody(byte[] signedPart, byte[] 
signature, String lineEnding) throws Exception {
+        return multipartSignedBody(signedPart, signature, lineEnding, 
BOUNDARY);
+    }
+
+    private static byte[] multipartSignedBody(byte[] signedPart, byte[] 
signature, String lineEnding, String boundary)
+            throws Exception {
+        ByteArrayOutputStream body = new ByteArrayOutputStream();
+        body.write(("--" + boundary + 
lineEnding).getBytes(StandardCharsets.US_ASCII));
+        body.write(signedPart);
+        body.write((lineEnding + "--" + boundary + lineEnding
+                    + "Content-Type: application/pkcs7-signature; 
name=smime.p7s" + lineEnding
+                    + "Content-Transfer-Encoding: base64" + lineEnding
+                    + lineEnding)
+                .getBytes(StandardCharsets.US_ASCII));
+        body.write(wrap(Base64.getEncoder().encodeToString(signature), 
76).getBytes(StandardCharsets.US_ASCII));
+        body.write((lineEnding + "--" + boundary + "--" + 
lineEnding).getBytes(StandardCharsets.US_ASCII));
+        return body.toByteArray();
+    }
+
+    private static String multipartSignedContentType(String boundary) {
+        return "multipart/signed; protocol=\"application/pkcs7-signature\"; 
micalg=sha-256; boundary=\"" + boundary + "\"";
+    }
+
+    /** The whole multipart/signed entity with its own headers, as it sits 
inside an encrypted message. */
+    private static byte[] multipartSignedEntity(byte[] signedPart, byte[] 
signature) throws Exception {
+        ByteArrayOutputStream entity = new ByteArrayOutputStream();
+        entity.write(("Content-Type: " + MULTIPART_SIGNED_CONTENT_TYPE + 
"\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
+        entity.write(multipartSignedBody(signedPart, signature));
+        return entity.toByteArray();
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "base64", "quoted-printable", "binary" })
+    void signatureIsVerifiedOverThePartAsReceived(String transferEncoding) 
throws Exception {
+        byte[] signedPart = signedPart(transferEncoding);
+        byte[] signature = sign(signedPart);
+
+        MimeEntity entity = 
EntityParser.parseEntity(multipartSignedEntity(signedPart, signature));
+
+        MultipartSignedEntity multipartSignedEntity = 
assertInstanceOf(MultipartSignedEntity.class, entity);
+        assertArrayEquals(signedPart, multipartSignedEntity.getSignedDataRaw(),
+                "the parser must keep the signed part exactly as received");
+        assertTrue(SigningUtils.isValid(multipartSignedEntity, new 
Certificate[] { signingCert }),
+                "a signature over the part as transmitted must verify");
+        assertFalse(SigningUtils.isValid(multipartSignedEntity, new 
Certificate[] { otherCert }),
+                "a signature must not verify against another certificate");
+
+        ApplicationEntity ediEntity = 
assertInstanceOf(ApplicationEntity.class, 
multipartSignedEntity.getSignedDataEntity());
+        assertEquals(EDI_MESSAGE, payloadOf(ediEntity), "the decoded payload 
must be unchanged");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "base64", "quoted-printable" })
+    void reserialisingTheParsedPartDoesNotGiveTheSignedBytes(String 
transferEncoding) throws Exception {
+        byte[] signedPart = signedPart(transferEncoding);
+        byte[] signature = sign(signedPart);
+
+        MultipartSignedEntity multipartSignedEntity = (MultipartSignedEntity) 
EntityParser
+                .parseEntity(multipartSignedEntity(signedPart, signature));
+
+        ByteArrayOutputStream reserialised = new ByteArrayOutputStream();
+        multipartSignedEntity.getSignedDataEntity().writeTo(reserialised);
+        assertFalse(Arrays.equals(signedPart, reserialised.toByteArray()),
+                "the re-serialised part differs from what the sender 
transmitted - verifying over it is wrong");
+        assertFalse(SigningUtils.isValidSigned(reserialised.toByteArray(), 
signature, new Certificate[] { signingCert }),
+                "the signature does not verify over the re-serialised part");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "base64", "quoted-printable", "binary" })
+    void micIsComputedOverThePartAsReceived(String transferEncoding) throws 
Exception {
+        byte[] signedPart = signedPart(transferEncoding);
+        byte[] signature = sign(signedPart);
+
+        BasicClassicHttpRequest request = new BasicClassicHttpRequest("POST", 
"/");
+        request.addHeader(AS2Header.CONTENT_TYPE, 
MULTIPART_SIGNED_CONTENT_TYPE);
+        request.addHeader(AS2Header.DISPOSITION_NOTIFICATION_OPTIONS, 
DISPOSITION_NOTIFICATION_OPTIONS);
+        request.setEntity(new BasicHttpEntity(
+                new ByteArrayInputStream(multipartSignedBody(signedPart, 
signature)),
+                ContentType.parse(MULTIPART_SIGNED_CONTENT_TYPE)));
+
+        MicUtils.ReceivedContentMic mic
+                = MicUtils.createReceivedContentMic(request, new Certificate[] 
{ signingCert }, null);
+
+        assertNotNull(mic, "a MIC must be created");
+        byte[] digest = 
MessageDigest.getInstance("SHA-256").digest(signedPart);
+        assertEquals(Base64.getEncoder().encodeToString(digest), 
mic.getEncodedMessageDigest(),
+                "the MIC must cover the signed part as transmitted - the 
sender computes it over the same bytes");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "base64", "binary" })
+    void 
senderFramingTheBoundariesWithBareLineFeedsIsVerifiedAndParsedAlike(String 
transferEncoding) throws Exception {
+        // openssl cms -sign without -crlfeol: a bare LF before each delimiter 
line. The signature covers the part up
+        // to its last byte - and so must the parsed payload, which once lost 
that byte to a CRLF assumption
+        byte[] signedPart = signedPart(transferEncoding);
+        byte[] signature = sign(signedPart);
+        byte[] body = multipartSignedBody(signedPart, signature, "\n");
+
+        ByteArrayOutputStream entityBytes = new ByteArrayOutputStream();
+        entityBytes.write(("Content-Type: " + MULTIPART_SIGNED_CONTENT_TYPE + 
"\n\n").getBytes(StandardCharsets.US_ASCII));
+        entityBytes.write(body);
+        MultipartSignedEntity multipartSignedEntity
+                = assertInstanceOf(MultipartSignedEntity.class, 
EntityParser.parseEntity(entityBytes.toByteArray()));
+
+        assertArrayEquals(signedPart, 
multipartSignedEntity.getSignedDataRaw(), "the part as received, to its last 
byte");
+        assertTrue(SigningUtils.isValid(multipartSignedEntity, new 
Certificate[] { signingCert }));
+        ApplicationEntity ediEntity = 
assertInstanceOf(ApplicationEntity.class, 
multipartSignedEntity.getSignedDataEntity());
+        assertEquals(EDI_MESSAGE, payloadOf(ediEntity), "the parsed payload is 
the whole part, its last byte included");
+
+        BasicClassicHttpRequest request = new BasicClassicHttpRequest("POST", 
"/");
+        request.addHeader(AS2Header.CONTENT_TYPE, 
MULTIPART_SIGNED_CONTENT_TYPE);
+        request.addHeader(AS2Header.DISPOSITION_NOTIFICATION_OPTIONS, 
DISPOSITION_NOTIFICATION_OPTIONS);
+        request.setEntity(
+                new BasicHttpEntity(new ByteArrayInputStream(body), 
ContentType.parse(MULTIPART_SIGNED_CONTENT_TYPE)));
+        MicUtils.ReceivedContentMic mic = 
MicUtils.createReceivedContentMic(request, new Certificate[] { signingCert }, 
null);
+        
assertEquals(Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(signedPart)),
+                mic.getEncodedMessageDigest(), "the MIC covers the same 
bytes");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "", "\r\n" })
+    void multipartSignedInsideMultipartSignedKeepsBothPartsAsReceived(String 
lineAfterInnerCloseDelimiter) throws Exception {
+        // the outer signed part is itself a multipart/signed: both signatures 
verify over their own part as received,
+        // whether or not a blank line separates the inner close delimiter 
from the outer delimiter
+        String innerBoundary = "----=_Part_inner_3";
+        byte[] signedPart = signedPart("base64");
+        byte[] innerSignature = sign(signedPart);
+        ByteArrayOutputStream outerPart = new ByteArrayOutputStream();
+        outerPart.write(("Content-Type: " + 
multipartSignedContentType(innerBoundary) + "\r\n\r\n")
+                .getBytes(StandardCharsets.US_ASCII));
+        byte[] innerBody = multipartSignedBody(signedPart, innerSignature, 
"\r\n", innerBoundary);
+        // up to the inner close delimiter: the line break after it belongs to 
the outer boundary ...
+        outerPart.write(innerBody, 0, innerBody.length - 2);
+        // ... unless a blank line follows, which is then the line the outer 
boundary's line break ends
+        
outerPart.write(lineAfterInnerCloseDelimiter.getBytes(StandardCharsets.US_ASCII));
+        byte[] outerSignedPart = outerPart.toByteArray();
+        byte[] outerSignature = sign(outerSignedPart);
+
+        ByteArrayOutputStream message = new ByteArrayOutputStream();
+        message.write(("Content-Type: " + MULTIPART_SIGNED_CONTENT_TYPE + 
"\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
+        message.write(multipartSignedBody(outerSignedPart, outerSignature));
+
+        MultipartSignedEntity outer
+                = assertInstanceOf(MultipartSignedEntity.class, 
EntityParser.parseEntity(message.toByteArray()));
+        assertArrayEquals(outerSignedPart, outer.getSignedDataRaw(), "the 
outer part as received, the inner inside it");
+        assertTrue(SigningUtils.isValid(outer, new Certificate[] { signingCert 
}), "the outer signature verifies");
+
+        MultipartSignedEntity inner = 
assertInstanceOf(MultipartSignedEntity.class, outer.getSignedDataEntity());
+        assertArrayEquals(signedPart, inner.getSignedDataRaw(), "the inner 
part as received");
+        assertTrue(SigningUtils.isValid(inner, new Certificate[] { signingCert 
}), "the inner signature verifies");
+        ApplicationEntity ediEntity = 
assertInstanceOf(ApplicationEntity.class, inner.getSignedDataEntity());
+        assertEquals(EDI_MESSAGE, payloadOf(ediEntity));
+    }
+
+    @Test
+    void signatureInsideAnEncryptedMessageIsVerifiedOverThePartAsReceived() 
throws Exception {
+        byte[] signedPart = signedPart("base64");
+        byte[] signature = sign(signedPart);
+
+        // encrypted for the receiver, as a partner's software does: the 
multipart/signed with its headers is the
+        // enveloped content
+        CMSEnvelopedDataGenerator envelopedDataGenerator = new 
CMSEnvelopedDataGenerator();
+        envelopedDataGenerator.addRecipientInfoGenerator(new 
JceKeyTransRecipientInfoGenerator(signingCert));
+        byte[] encrypted = envelopedDataGenerator.generate(
+                new CMSProcessableByteArray(multipartSignedEntity(signedPart, 
signature)),
+                new 
JceCMSContentEncryptorBuilder(CMSAlgorithm.AES256_CBC).build()).getEncoded();
+
+        ApplicationPkcs7MimeEnvelopedDataEntity envelopedEntity
+                = new ApplicationPkcs7MimeEnvelopedDataEntity(encrypted, 
"binary", true);
+        MimeEntity decrypted = 
envelopedEntity.getEncryptedEntity(signingKP.getPrivate());
+
+        MultipartSignedEntity multipartSignedEntity = 
assertInstanceOf(MultipartSignedEntity.class, decrypted);
+        assertArrayEquals(signedPart, 
multipartSignedEntity.getSignedDataRaw());
+        assertTrue(SigningUtils.isValid(multipartSignedEntity, new 
Certificate[] { signingCert }),
+                "a signature over the part as transmitted must verify after 
decryption too");
+    }
+
+    @Test
+    void entityBuiltLocallyIsVerifiedOverItsSerialisation() throws Exception {
+        // no "as received" bytes exist for an entity camel-as2 built itself: 
its serialisation is what is signed
+        ApplicationEDIFACTEntity ediEntity = new ApplicationEDIFACTEntity(
+                EDI_MESSAGE.getBytes(StandardCharsets.US_ASCII), 
StandardCharsets.US_ASCII.name(), "base64", false,
+                "orders.edi");
+        AS2SignedDataGenerator generator = 
SigningUtils.createSigningGenerator(AS2SignatureAlgorithm.SHA256WITHRSA,
+                new Certificate[] { signingCert }, signingKP.getPrivate());
+        MultipartSignedEntity multipartSignedEntity
+                = new MultipartSignedEntity(ediEntity, generator, 
StandardCharsets.US_ASCII.name(), "base64", true, null);
+
+        assertNull(multipartSignedEntity.getSignedDataRaw(), "nothing was 
received for a locally built entity");
+        assertNotNull(multipartSignedEntity.getSignedDataContent());
+        assertTrue(SigningUtils.isValid(multipartSignedEntity, new 
Certificate[] { signingCert }));
+    }
+}
diff --git 
a/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/io/AS2SessionInputBufferTest.java
 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/io/AS2SessionInputBufferTest.java
index dcdb5b533f2d..895910aa4f11 100644
--- 
a/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/io/AS2SessionInputBufferTest.java
+++ 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/io/AS2SessionInputBufferTest.java
@@ -25,8 +25,10 @@ import 
org.apache.hc.core5.http.impl.BasicHttpTransportMetrics;
 import org.apache.hc.core5.util.CharArrayBuffer;
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class AS2SessionInputBufferTest {
@@ -117,4 +119,96 @@ class AS2SessionInputBufferTest {
         assertEquals("q", readLine(inbuffer, is));
         assertTrue(inbuffer.isLastLineReadEnrichedByCarriageReturn(), "Line 
terminated by CRLF");
     }
+
+    @Test
+    void rawCaptureKeepsTheBytesReadLineConsumes() throws Exception {
+        // CRLF, bare LF and a last line without terminator, plus a line 
longer than the buffer
+        String longLine = "x".repeat(50);
+        String content = "first\r\n" + longLine + "\nlast";
+        InputStream is = new 
ByteArrayInputStream(content.getBytes(StandardCharsets.US_ASCII));
+        AS2SessionInputBuffer buffer = new AS2SessionInputBuffer(new 
BasicHttpTransportMetrics(), 16);
+
+        assertNull(buffer.stopRawCapture(), "no capture open");
+
+        buffer.startRawCapture();
+        assertEquals("first", readLine(buffer, is));
+        assertEquals(longLine, readLine(buffer, is), "a line assembled across 
buffer refills");
+        assertEquals("last", readLine(buffer, is), "no terminator at the end 
of the stream");
+        assertEquals(-1, buffer.readLine(new CharArrayBuffer(16), is));
+
+        assertArrayEquals(content.getBytes(StandardCharsets.US_ASCII), 
buffer.stopRawCapture(),
+                "every byte consumed, exactly as received");
+        assertNull(buffer.stopRawCapture(), "a capture can only be stopped 
once");
+    }
+
+    @Test
+    void rawCaptureKeepsALineEndingSplitAcrossTwoReads() throws Exception {
+        // the CR arrives with one read, the LF with the next: both belong to 
the first line
+        AS2SessionInputBuffer buffer = new AS2SessionInputBuffer(new 
BasicHttpTransportMetrics(), 64);
+        InputStream is = new ChunkedInputStream("abc\r", "\ndef\r\n");
+
+        buffer.startRawCapture();
+        assertEquals("abc", readLine(buffer, is));
+        assertEquals("def", readLine(buffer, is));
+
+        
assertArrayEquals("abc\r\ndef\r\n".getBytes(StandardCharsets.US_ASCII), 
buffer.stopRawCapture());
+    }
+
+    @Test
+    void rawCaptureStartsAndEndsWhereAsked() throws Exception {
+        String content = "skipped\r\ncaptured\r\nafter\r\n";
+        InputStream is = new 
ByteArrayInputStream(content.getBytes(StandardCharsets.US_ASCII));
+        AS2SessionInputBuffer buffer = new AS2SessionInputBuffer(new 
BasicHttpTransportMetrics(), 1024);
+
+        readLine(buffer, is);
+        buffer.startRawCapture();
+        readLine(buffer, is);
+        byte[] captured = buffer.stopRawCapture();
+
+        assertArrayEquals("captured\r\n".getBytes(StandardCharsets.US_ASCII), 
captured);
+        assertEquals("after", readLine(buffer, is), "reading goes on after the 
capture");
+    }
+
+    @Test
+    void rawCapturesNest() throws Exception {
+        // a part inside a part: the inner capture is a piece of the outer 
one, which goes on after it
+        String content = "outer 1\r\ninner\r\nouter 2\r\n";
+        InputStream is = new 
ByteArrayInputStream(content.getBytes(StandardCharsets.US_ASCII));
+        AS2SessionInputBuffer buffer = new AS2SessionInputBuffer(new 
BasicHttpTransportMetrics(), 1024);
+
+        buffer.startRawCapture();
+        readLine(buffer, is);
+        buffer.startRawCapture();
+        readLine(buffer, is);
+        byte[] inner = buffer.stopRawCapture();
+        readLine(buffer, is);
+        byte[] outer = buffer.stopRawCapture();
+
+        assertArrayEquals("inner\r\n".getBytes(StandardCharsets.US_ASCII), 
inner, "the inner capture");
+        assertArrayEquals(content.getBytes(StandardCharsets.US_ASCII), outer, 
"the outer capture, inner part included");
+        assertNull(buffer.stopRawCapture(), "both captures are closed");
+    }
+
+    @Test
+    void excludedLastLineIsLeftOutOfTheCaptureStoppedNext() throws Exception {
+        // a body-part parser read the boundary delimiter line: it and the 
CRLF before it belong to the boundary
+        String content = "data\r\n--boundary\r\nmore\r\n--boundary\r\n";
+        InputStream is = new 
ByteArrayInputStream(content.getBytes(StandardCharsets.US_ASCII));
+        AS2SessionInputBuffer buffer = new AS2SessionInputBuffer(new 
BasicHttpTransportMetrics(), 1024);
+
+        buffer.startRawCapture();
+        assertEquals("data", readLine(buffer, is));
+        assertEquals("--boundary", readLine(buffer, is));
+        buffer.excludeLastLineFromRawCapture(2);
+        assertArrayEquals("data".getBytes(StandardCharsets.US_ASCII), 
buffer.stopRawCapture(),
+                "the delimiter line and the line break before it are not part 
of the part");
+
+        // the exclusion holds for the capture stopped next only, and the next 
readLine forgets it
+        buffer.startRawCapture();
+        assertEquals("more", readLine(buffer, is));
+        buffer.excludeLastLineFromRawCapture(2);
+        assertEquals("--boundary", readLine(buffer, is));
+        
assertArrayEquals("more\r\n--boundary\r\n".getBytes(StandardCharsets.US_ASCII), 
buffer.stopRawCapture(),
+                "an exclusion followed by another readLine is void");
+    }
 }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index 4203d74b63b0..0569602b5cdc 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -13,6 +13,22 @@ See the xref:camel-upgrade-recipes-tool.adoc[documentation] 
page for details.
 
 == Upgrading from 4.22.2 to 4.22.3
 
+=== camel-as2 - server/listen - signature and MIC over the message as received
+
+The signature of a `multipart/signed` message is now verified over the signed 
part exactly as it was received, its
+MIME headers and its body in the transfer encoding the sender used (RFC 1847, 
section 2.1), and the `Received-Content-MIC`
+of the MDN is computed over the same bytes (RFC 4130, section 7.1). Previously 
both were computed over a
+re-serialisation of the parsed part, which matches the sender's bytes only 
when the part is `binary`, `7bit` or
+`8bit` and its headers are formatted the way camel-as2 writes them: a `base64` 
or `quoted-printable` part from another
+AS2 implementation, encoded with a different line length or escaping, or a 
part with folded headers or no space after
+the colon, was refused with `authentication-failed` although correctly signed, 
and the MDN carried a MIC the sender
+could not match. Messages from camel-as2's own client are not affected. 
`MultipartSignedEntity.getSignedDataRaw()`
+returns the part as received, `getSignedDataContent()` the bytes a signature 
is verified against.
+
+The text of a body part now ends exactly where the boundary delimiter begins: 
the line break before the delimiter
+line is removed as the sender wrote it, CRLF or a bare LF. Before, two 
characters were always removed, so a part
+framed with bare line feeds (as `openssl cms -sign` writes them without 
`-crlfeol`) lost its last byte.
+
 === camel-azure-eventhubs - an event that completes when the checkpoint timer 
of its window has run is checkpointed
 
 With `checkpointBatchTimeout`, the consumer now starts a new checkpoint window 
when the timer of the current window

Reply via email to