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