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

bvahdat pushed a commit to branch CAMEL-26425-as2-signature-as-received
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 7ae1ebd0973c3dc4d69e3bfad5ad78e5e7ea7b9a
Author: Babak Vahdat <[email protected]>
AuthorDate: Sat Oct 31 00:27:40 2026 +0100

    CAMEL-26425: camel-as2 - verify the signature and compute the MIC over the 
signed part as received
    
    The signature of a multipart/signed message was verified over a 
re-serialisation of the parsed first
    body part, and the Received-Content-MIC of the MDN was computed over the 
same re-serialisation. Both
    only match the sender's bytes for binary, 7bit and 8bit parts: a base64 or 
quoted-printable part from
    another AS2 implementation, encoded with a different line length or 
escaping, was refused with
    authentication-failed although correctly signed (RFC 1847, 2.1), and the 
MDN carried a MIC the sender
    could not match (RFC 4130, 7.1).
    
    - AS2SessionInputBuffer: a raw capture of the bytes readLine consumes, line 
terminators included
    - EntityParser: keeps the signed part as received, without the boundary 
delimiter that ends it
    - MultipartSignedEntity: getSignedDataRaw() (as received, null when built 
locally) and
      getSignedDataContent() (the bytes a signature is verified against, 
serialisation as fallback)
    - SigningUtils.isValid and MicUtils use getSignedDataContent(); camel-as2's 
own client is unchanged
    - Tests: MultipartSignedEntityAsReceivedTest, AS2SessionInputBufferTest; 
upgrade guide entry for 4.24
    
    Co-authored-by: Claude Fable 5.1 <[email protected]>
---
 .../component/as2/api/entity/EntityParser.java     |  33 +++
 .../as2/api/entity/MultipartSignedEntity.java      |  37 +++
 .../as2/api/io/AS2SessionInputBuffer.java          |  45 ++++
 .../camel/component/as2/api/util/MicUtils.java     |  52 ++--
 .../camel/component/as2/api/util/SigningUtils.java |  19 +-
 .../MultipartSignedEntityAsReceivedTest.java       | 275 +++++++++++++++++++++
 .../as2/api/io/AS2SessionInputBufferTest.java      |  71 ++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  11 +
 8 files changed, 512 insertions(+), 31 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..987f72f680c5 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
@@ -24,6 +24,7 @@ import java.nio.charset.CharsetDecoder;
 import java.nio.charset.StandardCharsets;
 import java.security.PrivateKey;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Iterator;
 import java.util.List;
@@ -568,6 +569,11 @@ 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();
+
             // Read Text Report Body Part Headers
             Header[] headers = AbstractMessageParser.parseHeaders(inbuffer, 
is, -1, -1, BasicLineParser.INSTANCE,
                     new ArrayList<>());
@@ -588,6 +594,10 @@ public final class EntityParser {
 
             MimeEntity signedEntity = parseEntityBody(inbuffer, is, boundary, 
signedEntityContentType,
                     signedEntityContentTransferEncoding, "", headers);
+            // parseEntityBody consumed the boundary delimiter line that ends 
the part; that line and the line
+            // break before it belong to the boundary, not to the part (RFC 
2046, 5.1.1)
+            multipartSignedEntity.setSignedDataRaw(
+                    stripBoundaryDelimiter(inbuffer.stopRawCapture(), 
inbuffer.getLastLineRawLength(), boundary));
             signedEntity.removeAllHeaders();
             signedEntity.setHeaders(headers);
             multipartSignedEntity.addPart(signedEntity);
@@ -637,10 +647,33 @@ public final class EntityParser {
             parseException.initCause(e);
             throw parseException;
         } finally {
+            inbuffer.stopRawCapture();
             inbuffer.setCharsetDecoder(previousDecoder);
         }
     }
 
+    /**
+     * Removes the boundary delimiter line a body-part parser consumed last, 
and the line break preceding it, from the
+     * raw bytes of the part - both belong to the boundary (RFC 2046, 5.1.1). 
Returns the bytes unchanged when the last
+     * line read is not that delimiter.
+     */
+    static byte[] stripBoundaryDelimiter(byte[] raw, int lastLineRawLength, 
String boundary) {
+        if (raw == null || lastLineRawLength <= 0 || lastLineRawLength > 
raw.length) {
+            return raw;
+        }
+        int end = raw.length - lastLineRawLength;
+        String lastLine = new String(raw, end, lastLineRawLength, 
StandardCharsets.US_ASCII);
+        if (!lastLine.startsWith("--" + boundary)) {
+            return raw;
+        }
+        if (end >= 2 && raw[end - 2] == CR && raw[end - 1] == LF) {
+            end -= 2;
+        } else if (end >= 1 && raw[end - 1] == LF) {
+            end -= 1;
+        }
+        return Arrays.copyOf(raw, end);
+    }
+
     // DispositionNotificationMultipartReportEntity must be closed by the 
client after using it.
     public static DispositionNotificationMultipartReportEntity 
parseMultipartReportEntityBody(
             AS2SessionInputBuffer inbuffer,
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..b9a4959a8d1e 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,40 @@ 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.
+     */
+    public byte[] getSignedDataRaw() {
+        return signedDataRaw;
+    }
+
+    public 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
+     */
+    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..8710b3ce3aac 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
@@ -51,6 +51,11 @@ 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.
+    private ByteArrayBuffer rawCapture;
+    private int lastLineRawLength;
 
     public AS2SessionInputBuffer(final BasicHttpTransportMetrics metrics,
                                  final int buffersize,
@@ -179,6 +184,7 @@ public class AS2SessionInputBuffer implements 
SessionInputBuffer {
         boolean retry = true;
         this.lastLineReadEnrichedByCarriageReturn = false;
         this.lastLineReadTerminatedByLineFeed = false;
+        this.lastLineRawLength = 0;
         while (retry) {
             // attempt to find end of line (LF)
             int pos = -1;
@@ -233,12 +239,50 @@ 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 {@link 
#stopRawCapture()}.
+     */
+    public void startRawCapture() {
+        this.rawCapture = new ByteArrayBuffer(this.buffer.length);
+    }
+
+    /**
+     * Ends a capture started by {@link #startRawCapture()}.
+     *
+     * @return the bytes consumed by {@code readLine} since the capture 
started, or null when no capture is active
+     */
+    public byte[] stopRawCapture() {
+        if (this.rawCapture == null) {
+            return null;
+        }
+        byte[] captured = this.rawCapture.toByteArray();
+        this.rawCapture = null;
+        return captured;
+    }
+
+    /**
+     * The number of raw bytes, line terminator included, the last {@link 
#readLine(CharArrayBuffer, InputStream)}
+     * consumed - 0 when it hit the end of the stream.
+     */
+    public int getLastLineRawLength() {
+        return lastLineRawLength;
+    }
+
+    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 +313,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 294e83b9b189..003f175bac6b 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)) {
@@ -179,7 +183,7 @@ public final class MicUtils {
                             .getCompressedEntity(
                                     new 
ZlibExpanderProvider(HttpMessageUtils.MAX_COMPRESSED_ENTITY_EXPANSION));
                     if (inner instanceof MultipartSignedEntity signedEntity) {
-                        return signedEntity.getSignedDataEntity();
+                        return signedEntity;
                     }
                 }
             } else if ("enveloped-data".equals(smimeType) && 
decryptingPrivateKey != null) {
@@ -194,7 +198,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/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..6a3bc4d51170
--- /dev/null
+++ 
b/components/camel-as2/camel-as2-api/src/test/java/org/apache/camel/component/as2/api/entity/MultipartSignedEntityAsReceivedTest.java
@@ -0,0 +1,275 @@
+/*
+ * 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 {
+        ByteArrayOutputStream body = new ByteArrayOutputStream();
+        body.write(("--" + BOUNDARY + 
"\r\n").getBytes(StandardCharsets.US_ASCII));
+        body.write(signedPart);
+        body.write(("\r\n--" + BOUNDARY + "\r\n"
+                    + "Content-Type: application/pkcs7-signature; 
name=smime.p7s\r\n"
+                    + "Content-Transfer-Encoding: base64\r\n"
+                    + "\r\n")
+                .getBytes(StandardCharsets.US_ASCII));
+        body.write(wrap(Base64.getEncoder().encodeToString(signature), 
76).getBytes(StandardCharsets.US_ASCII));
+        body.write(("\r\n--" + BOUNDARY + 
"--\r\n").getBytes(StandardCharsets.US_ASCII));
+        return body.toByteArray();
+    }
+
+    /** 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");
+    }
+
+    @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..1430b91024b8 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,73 @@ 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);
+        CharArrayBuffer line = new CharArrayBuffer(64);
+
+        assertNull(buffer.stopRawCapture(), "no capture active");
+
+        buffer.startRawCapture();
+        buffer.readLine(line, is);
+        assertEquals("first", line.toString());
+        assertEquals(7, buffer.getLastLineRawLength(), "CRLF counted");
+
+        line.clear();
+        buffer.readLine(line, is);
+        assertEquals(longLine, line.toString());
+        assertEquals(51, buffer.getLastLineRawLength(), "a line assembled 
across buffer refills, LF counted");
+
+        line.clear();
+        buffer.readLine(line, is);
+        assertEquals("last", line.toString());
+        assertEquals(4, buffer.getLastLineRawLength(), "no terminator at the 
end of the stream");
+
+        line.clear();
+        assertEquals(-1, buffer.readLine(line, is));
+        assertEquals(0, buffer.getLastLineRawLength());
+
+        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(5, buffer.getLastLineRawLength(), "CRLF counted although 
split");
+        assertEquals("def", readLine(buffer, is));
+        assertEquals(5, buffer.getLastLineRawLength());
+
+        
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);
+        CharArrayBuffer line = new CharArrayBuffer(64);
+
+        buffer.readLine(line, is);
+        buffer.startRawCapture();
+        line.clear();
+        buffer.readLine(line, is);
+        byte[] captured = buffer.stopRawCapture();
+        line.clear();
+        buffer.readLine(line, is);
+
+        assertArrayEquals("captured\r\n".getBytes(StandardCharsets.US_ASCII), 
captured);
+        assertEquals("after", line.toString(), "reading goes on after the 
capture");
+    }
 }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 936a5773a1be..b597d1a8016c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -3798,6 +3798,17 @@ type and the `CamelDnsTerm` header are deprecated, as 
the service behind them is
 * `ediMessageTransferEncoding=quoted-printable` now encodes the message as 
quoted-printable. Previously it was
   encoded as Base64 but labelled quoted-printable, which the receiver rejected.
 
+=== 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 only matches for `binary`, `7bit` 
and `8bit` parts: a `base64` or
+`quoted-printable` part from another AS2 implementation, encoded with a 
different line length or escaping, 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.
+
 === camel-graphql - the HTTP headers of the request
 
 The `Content-Type`, `Accept` and `Accept-Encoding` headers of the request are 
now always the ones of the producer

Reply via email to