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
