This is an automated email from the ASF dual-hosted git repository.
davsclaus 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 837b0650cdb2 CAMEL-24440: camel-crypto - per-message IV when inlining,
and uniform authentication failures
837b0650cdb2 is described below
commit 837b0650cdb2b249b5cc1aef0d328422e61537d8
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 8 15:31:10 2026 +0200
CAMEL-24440: camel-crypto - per-message IV when inlining, and uniform
authentication failures
Backport of #26725 to camel-4.22.x.
CryptoDataFormat now generates a per-message initialization vector when
inlining is enabled without a configured vector. It reports a single
"Message authentication failed" error for both bad padding and MAC
mismatch, so a tampered message no longer works as a padding oracle. The
failure message no longer includes the MAC values, and the length of the
inlined vector is now bounded. None of these changes affects the format
of data that was already written.
The upgrade note goes into the 4.22.2 section of the 4.22 upgrade guide.
CryptoDataFormatLargePayloadTest is restored to match main, since it
expected the old MAC mismatch message.
Closes #27490
Co-Authored-By: Andrea Cosentino <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../camel/converter/crypto/CryptoDataFormat.java | 75 +++++++-
.../camel/converter/crypto/HMACAccumulator.java | 16 +-
.../crypto/CryptoDataFormatIvAndFailureTest.java | 210 +++++++++++++++++++++
.../crypto/CryptoDataFormatLargePayloadTest.java | 7 +-
.../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 26 +++
5 files changed, 322 insertions(+), 12 deletions(-)
diff --git
a/components/camel-crypto/src/main/java/org/apache/camel/converter/crypto/CryptoDataFormat.java
b/components/camel-crypto/src/main/java/org/apache/camel/converter/crypto/CryptoDataFormat.java
index eb0449959608..09a0764b5a1e 100644
---
a/components/camel-crypto/src/main/java/org/apache/camel/converter/crypto/CryptoDataFormat.java
+++
b/components/camel-crypto/src/main/java/org/apache/camel/converter/crypto/CryptoDataFormat.java
@@ -21,7 +21,9 @@ import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.security.GeneralSecurityException;
import java.security.Key;
+import java.security.SecureRandom;
import java.security.spec.AlgorithmParameterSpec;
import javax.crypto.Cipher;
@@ -72,6 +74,13 @@ public class CryptoDataFormat extends ServiceSupport
implements DataFormat, Data
private static final Logger LOG =
LoggerFactory.getLogger(CryptoDataFormat.class);
private static final String INIT_VECTOR = "CamelCryptoInitVector";
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+ /**
+ * Upper bound on the length of an inlined initialization vector read from
the stream. A JCE initialization vector
+ * is at most a cipher block, so this is generous; the bound exists
because the length is read from the message and
+ * used directly to size an allocation.
+ */
+ private static final int MAX_INLINE_IV_LENGTH = 1024;
private String algorithm;
private String cryptoProvider;
private Key key;
@@ -81,6 +90,9 @@ public class CryptoDataFormat extends ServiceSupport
implements DataFormat, Data
private String macAlgorithm = "HmacSHA1";
private boolean shouldAppendHMAC = true;
private AlgorithmParameterSpec algorithmParameterSpec;
+ // The cipher block size is fixed by the algorithm, so it is looked up
once and cached rather than building a
+ // Cipher on every marshal purely to read it.
+ private volatile int cachedBlockSize;
public CryptoDataFormat() {
}
@@ -124,6 +136,20 @@ public class CryptoDataFormat extends ServiceSupport
implements DataFormat, Data
@Override
public void marshal(Exchange exchange, Object graph, OutputStream
outputStream) throws Exception {
byte[] iv = getInitializationVector(exchange);
+ if (iv == null && inline) {
+ if (algorithmParameterSpec != null) {
+ // initializeCipher gives algorithmParameterSpec precedence
over the IV, so a generated vector would be
+ // written into the message but never used - every message
would encrypt identically behind a vector
+ // that only looks per-message. Keep failing loudly, as this
configuration did before.
+ throw new IllegalStateException(
+ "Inlining cannot be performed when an
algorithmParameterSpec is configured, as the spec is"
+ + " used instead of the
initialization vector");
+ }
+ // The whole point of inlining is that the IV travels with the
message, so there is no reason to make
+ // the caller supply a fixed one - and requiring it is what used
to push users into reusing a single IV
+ // across every message.
+ iv = generateInitializationVector();
+ }
Key key = getKey(exchange);
InputStream plaintextStream =
ExchangeHelper.convertToMandatoryType(exchange, InputStream.class, graph);
@@ -166,8 +192,23 @@ public class CryptoDataFormat extends ServiceSupport
implements DataFormat, Data
byte[] buffer = new byte[bufferSize];
hmac.attachStream(osb);
int read;
- while ((read = cipherStream.read(buffer)) >= 0) {
- hmac.decryptUpdate(buffer, read);
+ try {
+ while ((read = cipherStream.read(buffer)) >= 0) {
+ hmac.decryptUpdate(buffer, read);
+ }
+ } catch (IOException e) {
+ if (shouldAppendHMAC && e.getCause() instanceof
GeneralSecurityException) {
+ // CipherInputStream surfaces bad padding as an
IOException wrapping
+ // BadPaddingException, while a bad MAC surfaces from
validate() below. Reporting the two
+ // differently is exactly what lets a caller who can
submit ciphertext and watch the
+ // outcome tell them apart, which is the
padding-oracle distinguisher. Report the same
+ // authentication failure for both - but only when a
MAC is actually appended: with
+ // shouldAppendHMAC=false nothing is authenticating,
so calling it an authentication failure
+ // would misdescribe a plain padding error and drop
its cause.
+ LOG.debug("Reporting cipher failure as an
authentication failure", e);
+ throw new
IllegalStateException(HMACAccumulator.AUTHENTICATION_FAILED);
+ }
+ throw e;
}
hmac.validate();
return osb.build();
@@ -207,6 +248,11 @@ public class CryptoDataFormat extends ServiceSupport
implements DataFormat, Data
if (inline) {
try {
int ivLength = new DataInputStream(encryptedStream).readInt();
+ if (ivLength < 0 || ivLength > MAX_INLINE_IV_LENGTH) {
+ throw new IOException(
+ String.format("Inlined initialization vector
length '%d' is not between 0 and %d",
+ ivLength, MAX_INLINE_IV_LENGTH));
+ }
iv = new byte[ivLength];
int read = encryptedStream.read(iv);
if (read != ivLength) {
@@ -247,6 +293,31 @@ public class CryptoDataFormat extends ServiceSupport
implements DataFormat, Data
};
}
+ /**
+ * A fresh initialization vector, sized to the cipher's block length. Only
used when the vector is inlined into the
+ * message, so the reader takes it from the stream and nothing needs to be
shared out of band.
+ */
+ private byte[] generateInitializationVector() throws Exception {
+ byte[] iv = new byte[getCipherBlockSize()];
+ SECURE_RANDOM.nextBytes(iv);
+ return iv;
+ }
+
+ private int getCipherBlockSize() throws Exception {
+ int blockSize = cachedBlockSize;
+ if (blockSize == 0) {
+ Cipher cipher
+ = cryptoProvider == null ? Cipher.getInstance(algorithm) :
Cipher.getInstance(algorithm, cryptoProvider);
+ blockSize = cipher.getBlockSize();
+ if (blockSize <= 0) {
+ // A stream cipher reports no block size; 16 bytes is the
usual nonce length
+ blockSize = 16;
+ }
+ cachedBlockSize = blockSize;
+ }
+ return blockSize;
+ }
+
private byte[] getInitializationVector(Exchange exchange) {
byte[] iv = exchange.getIn().getHeader(INIT_VECTOR, byte[].class);
if (iv == null) {
diff --git
a/components/camel-crypto/src/main/java/org/apache/camel/converter/crypto/HMACAccumulator.java
b/components/camel-crypto/src/main/java/org/apache/camel/converter/crypto/HMACAccumulator.java
index e1deb80f29f4..e0b7181fffa4 100644
---
a/components/camel-crypto/src/main/java/org/apache/camel/converter/crypto/HMACAccumulator.java
+++
b/components/camel-crypto/src/main/java/org/apache/camel/converter/crypto/HMACAccumulator.java
@@ -24,8 +24,6 @@ import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
-import static org.apache.camel.converter.crypto.HexUtils.byteArrayToHexString;
-
/**
* <code>HMACAccumulator</code> is used to build Hash Message Authentication
Codes. It has two modes, one where all the
* data acquired is used to build the MAC and a second that assumes that the
last n bytes of the acquired data will
@@ -96,15 +94,21 @@ public class HMACAccumulator {
return appended;
}
+ /**
+ * The single message reported for every authentication failure. Bad
padding and a bad MAC must be indistinguishable
+ * to a caller who can submit ciphertext and observe the outcome, because
telling them apart is what turns a CBC
+ * decryption into a padding oracle.
+ */
+ static final String AUTHENTICATION_FAILED = "Message authentication
failed";
+
public void validate() {
byte[] actual = getCalculatedMac();
byte[] expected = getAppendedMac();
// Use a constant-time comparison to avoid leaking MAC-match progress
through timing (side-channel).
if (!MessageDigest.isEqual(expected, actual)) {
- throw new IllegalStateException(
- "Expected mac did not match actual mac\nexpected:"
- + byteArrayToHexString(expected) +
"\n actual:"
- + byteArrayToHexString(actual));
+ // The computed MAC is HMAC_k over the plaintext that was just
produced, so reporting it hands the
+ // caller a value they could not otherwise compute. Neither MAC
belongs in the message.
+ throw new IllegalStateException(AUTHENTICATION_FAILED);
}
}
diff --git
a/components/camel-crypto/src/test/java/org/apache/camel/converter/crypto/CryptoDataFormatIvAndFailureTest.java
b/components/camel-crypto/src/test/java/org/apache/camel/converter/crypto/CryptoDataFormatIvAndFailureTest.java
new file mode 100644
index 000000000000..a5c9ff59d717
--- /dev/null
+++
b/components/camel-crypto/src/test/java/org/apache/camel/converter/crypto/CryptoDataFormatIvAndFailureTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.converter.crypto;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.Key;
+import java.util.Arrays;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.spec.IvParameterSpec;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.support.DefaultExchange;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CryptoDataFormatIvAndFailureTest {
+
+ private static final String PAYLOAD = "the quick brown fox jumps over the
lazy dog";
+
+ /**
+ * Inlining exists so the initialization vector travels with the message.
Requiring a statically configured one as
+ * well is what pushed routes into reusing a single vector for every
message.
+ */
+ @Test
+ void inliningGeneratesAFreshInitializationVectorPerMessage() throws
Exception {
+ Key key = key();
+ try (DefaultCamelContext context = new DefaultCamelContext()) {
+ context.start();
+ CryptoDataFormat encryptor = new
CryptoDataFormat("AES/CBC/PKCS5Padding", key);
+ encryptor.setShouldInlineInitializationVector(true);
+
+ byte[] first = marshal(context, encryptor, PAYLOAD);
+ byte[] second = marshal(context, encryptor, PAYLOAD);
+
+ assertFalse(Arrays.equals(first, second),
+ "the same plaintext must not produce identical ciphertext
twice");
+
+ CryptoDataFormat decryptor = new
CryptoDataFormat("AES/CBC/PKCS5Padding", key);
+ decryptor.setShouldInlineInitializationVector(true);
+ assertEquals(PAYLOAD, unmarshal(context, decryptor, first));
+ assertEquals(PAYLOAD, unmarshal(context, decryptor, second));
+ }
+ }
+
+ /**
+ * A caller who can submit ciphertext and observe the outcome must not be
able to tell a padding failure from a MAC
+ * failure - telling them apart is what turns CBC decryption into a
padding oracle.
+ */
+ @Test
+ void badPaddingAndBadMacAreReportedIdentically() throws Exception {
+ Key key = key();
+ try (DefaultCamelContext context = new DefaultCamelContext()) {
+ context.start();
+ // a static vector, not inlining, so this exercises the failure
reporting and nothing else
+ CryptoDataFormat format = new
CryptoDataFormat("AES/CBC/PKCS5Padding", key);
+ format.setInitVector(new byte[16]);
+
+ byte[] ciphertext = marshal(context, format, PAYLOAD);
+
+ // The MAC is written through the CipherOutputStream during
marshal, so it is encrypted inside the
+ // ciphertext, not appended in the clear. The last wire byte is
therefore the final ciphertext block, and
+ // flipping it makes that block no longer decrypt to valid PKCS5
padding - the BadPaddingException path,
+ // distinct from the bad-MAC path exercised just below.
+ byte[] badPadding = ciphertext.clone();
+ badPadding[badPadding.length - 1] ^= 0x01;
+
+ // corrupt a byte in the middle: padding still validates, the
appended MAC does not
+ byte[] badMac = ciphertext.clone();
+ badMac[badMac.length / 2] ^= 0x01;
+
+ String paddingFailure = failureMessage(context, format,
badPadding);
+ String macFailure = failureMessage(context, format, badMac);
+
+ assertEquals(macFailure, paddingFailure, "the two failures must be
indistinguishable");
+ assertTrue(paddingFailure.contains("authentication failed"),
"unexpected message: " + paddingFailure);
+ }
+ }
+
+ /**
+ * The inlined length is read from the message and used to size an
allocation, so it has to be bounded.
+ */
+ @Test
+ void anOversizedInlinedInitializationVectorLengthIsRejected() throws
Exception {
+ Key key = key();
+ try (DefaultCamelContext context = new DefaultCamelContext()) {
+ context.start();
+ CryptoDataFormat decryptor = new
CryptoDataFormat("AES/CBC/PKCS5Padding", key);
+ decryptor.setShouldInlineInitializationVector(true);
+
+ // a four byte length of 0x7FFFFFFF followed by nothing
+ byte[] hostile = { 0x7F, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF };
+
+ Exception e = assertThrows(Exception.class, () ->
unmarshal(context, decryptor, hostile));
+ assertTrue(rootMessage(e).contains("is not between 0 and"),
"unexpected message: " + rootMessage(e));
+ }
+ }
+
+ @Test
+ void aRoundTripWithAStaticVectorStillWorks() throws Exception {
+ Key key = key();
+ byte[] iv = new byte[16];
+ try (DefaultCamelContext context = new DefaultCamelContext()) {
+ context.start();
+ CryptoDataFormat format = new
CryptoDataFormat("AES/CBC/PKCS5Padding", key);
+ format.setInitVector(iv);
+
+ byte[] ciphertext = marshal(context, format, PAYLOAD);
+ assertEquals(PAYLOAD, unmarshal(context, format, ciphertext));
+ assertArrayEquals(iv, format.getInitVector());
+ }
+ }
+
+ /**
+ * Inlining plus a configured algorithmParameterSpec used to be a silent
IV-reuse trap: the spec wins in the cipher,
+ * so a generated per-message vector was written into the message but
never used. It must fail loudly.
+ */
+ @Test
+ void inliningWithAnAlgorithmParameterSpecIsRejected() throws Exception {
+ Key key = key();
+ try (DefaultCamelContext context = new DefaultCamelContext()) {
+ context.start();
+ CryptoDataFormat format = new
CryptoDataFormat("AES/CBC/PKCS5Padding", key);
+ format.setShouldInlineInitializationVector(true);
+ format.setAlgorithmParameterSpec(new IvParameterSpec(new
byte[16]));
+
+ IllegalStateException e = assertThrows(IllegalStateException.class,
+ () -> marshal(context, format, PAYLOAD));
+ assertTrue(e.getMessage().contains("algorithmParameterSpec"),
"unexpected message: " + e.getMessage());
+ }
+ }
+
+ /**
+ * With the MAC turned off there is nothing authenticating, so a cipher
failure must surface as itself rather than
+ * be relabelled "Message authentication failed" (which would also drop
the real cause).
+ */
+ @Test
+ void aCipherFailureWithoutAMacIsNotReportedAsAnAuthenticationFailure()
throws Exception {
+ Key key = key();
+ try (DefaultCamelContext context = new DefaultCamelContext()) {
+ context.start();
+ CryptoDataFormat format = new
CryptoDataFormat("AES/CBC/PKCS5Padding", key);
+ format.setInitVector(new byte[16]);
+ format.setShouldAppendHMAC(false);
+
+ byte[] ciphertext = marshal(context, format, PAYLOAD);
+ byte[] corrupted = ciphertext.clone();
+ corrupted[corrupted.length - 1] ^= 0x01;
+
+ String message = failureMessage(context, format, corrupted);
+ assertFalse(message.contains("authentication failed"),
+ "a cipher failure with no MAC must not be relabelled an
authentication failure: " + message);
+ }
+ }
+
+ private static String failureMessage(DefaultCamelContext context,
CryptoDataFormat format, byte[] body) {
+ Exception e = assertThrows(Exception.class, () -> unmarshal(context,
format, body));
+ return rootMessage(e);
+ }
+
+ private static String rootMessage(Throwable t) {
+ while (t.getCause() != null) {
+ t = t.getCause();
+ }
+ return String.valueOf(t.getMessage());
+ }
+
+ private static byte[] marshal(DefaultCamelContext context,
CryptoDataFormat format, String payload)
+ throws Exception {
+ Exchange exchange = new DefaultExchange(context);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ format.marshal(exchange, payload.getBytes(StandardCharsets.UTF_8),
out);
+ return out.toByteArray();
+ }
+
+ private static String unmarshal(DefaultCamelContext context,
CryptoDataFormat format, byte[] body)
+ throws Exception {
+ Exchange exchange = new DefaultExchange(context);
+ Object result = format.unmarshal(exchange, new
ByteArrayInputStream(body));
+ return context.getTypeConverter().convertTo(String.class, exchange,
result);
+ }
+
+ private static Key key() throws Exception {
+ KeyGenerator generator = KeyGenerator.getInstance("AES");
+ generator.init(128);
+ return generator.generateKey();
+ }
+}
diff --git
a/components/camel-crypto/src/test/java/org/apache/camel/converter/crypto/CryptoDataFormatLargePayloadTest.java
b/components/camel-crypto/src/test/java/org/apache/camel/converter/crypto/CryptoDataFormatLargePayloadTest.java
index 4e2196041764..20fc659df409 100644
---
a/components/camel-crypto/src/test/java/org/apache/camel/converter/crypto/CryptoDataFormatLargePayloadTest.java
+++
b/components/camel-crypto/src/test/java/org/apache/camel/converter/crypto/CryptoDataFormatLargePayloadTest.java
@@ -27,9 +27,9 @@ import org.apache.camel.test.junit6.CamelTestSupport;
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.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The HMAC (appended by default) is split off in a circular buffer on
unmarshal. The buffer must wrap around for any
@@ -71,15 +71,14 @@ public class CryptoDataFormatLargePayloadTest extends
CamelTestSupport {
// drop the last cipher block (DES has 8 byte blocks)
byte[] truncated = Arrays.copyOf(encrypted, encrypted.length - 8);
- // on this branch a truncated message fails on decryption (padding)
before the mac is checked
- assertThrows(CamelExecutionException.class, () ->
template.requestBody("direct:unmarshal", truncated));
+ assertAuthenticationFailed(truncated);
}
private void assertAuthenticationFailed(byte[] encrypted) {
CamelExecutionException e
= assertThrows(CamelExecutionException.class, () ->
template.requestBody("direct:unmarshal", encrypted));
IllegalStateException cause =
assertInstanceOf(IllegalStateException.class, e.getCause());
- assertTrue(cause.getMessage().startsWith("Expected mac did not match
actual mac"), cause.getMessage());
+ assertEquals(HMACAccumulator.AUTHENTICATION_FAILED,
cause.getMessage());
}
private void doRoundTrip(int size) throws Exception {
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 3dab963a9edd..8c2ad20d563e 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
@@ -47,6 +47,32 @@ applied does not receive the messages of the producers, and
it is closed when it
A WebSocket client that connected without satisfying the configured security
provider or allowed roles is now
rejected: it must authenticate, or the option must be removed from the
WebSocket endpoint.
+=== camel-crypto
+
+Three changes to `CryptoDataFormat`, none of which affects the format of data
already written.
+
+*A per-message initialization vector when inlining.* `marshal` used to throw
+`Inlining cannot be performed, as no initialization vector was specified` when
+`shouldInlineInitializationVector` was set without a statically configured
vector — which pushed routes
+into reusing one vector for every message, the thing inlining exists to avoid.
A fresh vector is now
+generated per message when none is supplied. Because the vector is written
into the message, readers pick
+it up from the stream and need no change. A vector supplied explicitly, by
configuration or by the
+`CamelCryptoInitVector` header, is still used as given.
+
+*Authentication failures report uniformly.* A tampered message previously
surfaced two distinguishable
+outcomes: `Given final block not properly padded` from the cipher, or
`Expected mac did not match actual
+mac` from the MAC check. A caller able to submit ciphertext and observe which
one came back can use that
+distinction to recover plaintext. Both now report `Message authentication
failed`, and the message no
+longer includes the expected and computed MAC values — the computed one is
`HMAC_k` over the plaintext
+just produced. Code matching on the old text must be updated.
+
+*The inlined vector length is bounded.* The length prefix is read from the
message and used to size an
+allocation; a declared length outside 0–1024 is now rejected instead of
attempted.
+
+Not changed: the HMAC key is still derived from the same key material as the
cipher. Separating them
+would change the MAC written into the message and so could not be read by
earlier versions; that is
+tracked separately.
+
== Upgrading from 4.22.0 to 4.22.1
=== camel-hazelcast - ReplicatedHazelcastAggregationRepository now applies the
default serialization filter