This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 4ebacf60e46c CAMEL-24440: camel-crypto - per-message IV when inlining,
and uniform authentication failures (#26725)
4ebacf60e46c is described below
commit 4ebacf60e46cc3e2d74a07076cabb7c9e268713a
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Sep 25 11:25:50 2026 +0200
CAMEL-24440: camel-crypto - per-message IV when inlining, and uniform
authentication failures (#26725)
* CAMEL-24440: camel-crypto - per-message IV when inlining, and uniform
authentication failures
Three changes to CryptoDataFormat, none of which alters the format of data
already
written.
Per-message initialization vector. marshal() threw "Inlining cannot be
performed, as no
initialization vector was specified" whenever
shouldInlineInitializationVector was set
without a statically configured vector, which pushed routes into
configuring one and
reusing it for every message - the thing inlining exists to avoid. A fresh
vector is now
generated per message when none is supplied. The vector is written into the
message, so
readers take it from the stream and need no change; a vector supplied
explicitly, by
configuration or by the CamelCryptoInitVector header, is still used as
given.
Uniform authentication failure. A tampered message surfaced two
distinguishable
outcomes - "Given final block not properly padded" from CipherInputStream,
or "Expected
mac did not match actual mac" from the MAC check - and a caller who can
submit
ciphertext and observe which one came back can use that to recover
plaintext. Both now
report "Message authentication failed". Checked on JDK 21 that
CipherInputStream does
surface bad padding as an IOException wrapping BadPaddingException, since
older JDKs
swallowed it.
MAC values out of the failure message. It carried both MACs in hex, and the
computed one
is HMAC_k over the plaintext just produced, so it handed the caller a value
they could
not otherwise compute.
Also bound the inlined vector length, which was read from the message and
used directly
to size an allocation.
Left alone: the HMAC key is still derived from the same material as the
cipher key.
Separating them changes the MAC written into the message, so neither
direction stays
readable across versions - that needs an opt-in or a format version marker
and is
tracked on the issue, along with the MAC-then-encrypt composition, where
validating
before streaming would mean buffering the whole plaintext.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24440: camel-crypto - cache the cipher block size and clarify the
failure-reporting test
Addresses review feedback on #26725:
- generateInitializationVector no longer builds a throwaway Cipher on every
marshal just to read the block size; the size is fixed by the algorithm,
so
it is looked up once and cached.
- clarified the failure-reporting test's comment: the MAC is encrypted
inside
the ciphertext (written through the CipherOutputStream), so flipping the
last
byte corrupts the final block's padding - the BadPaddingException path -
which
the test already exercises.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* CAMEL-24440: camel-crypto - address review on inline IV, MAC-off
failures, and test style
Addresses davsclaus's review on #26725:
- inlining with a configured algorithmParameterSpec now fails loudly
instead of
generating a per-message IV the cipher ignores (the spec wins in
initializeCipher), which silently reused one IV across every message.
- the BadPaddingException -> "Message authentication failed" rewrite is now
gated
on shouldAppendHMAC: with the MAC off nothing is authenticating, so the
real
cipher cause is surfaced (at debug) rather than misdescribed.
- moved MAX_INLINE_IV_LENGTH and SECURE_RANDOM up with the other class
fields.
- imported java.util.Arrays and java.io.ByteArrayInputStream in the test
rather
than using fully-qualified names (OpenRewrite / CI uncommitted-changes
check).
- added tests for both behaviour fixes, each verified to fail against the
un-fixed code.
Co-authored-by: Claude Opus 4.8 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
* Update
docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
Co-authored-by: Guillaume Nodet - AI Bot <[email protected]>
---------
Signed-off-by: Andrea Cosentino <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: Guillaume Nodet - AI Bot <[email protected]>
---
.../camel/converter/crypto/CryptoDataFormat.java | 75 +++++++-
.../camel/converter/crypto/HMACAccumulator.java | 16 +-
.../crypto/CryptoDataFormatIvAndFailureTest.java | 210 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 25 +++
4 files changed, 318 insertions(+), 8 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 a1482c3bb0ed..96bd1df07b34 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/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index fdfc1d31cc00..7e9e6024b2e4 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -2844,6 +2844,31 @@ are unaffected. Routes that set the header by its
literal string name, or that u
`allowTemplateFromHeader=true` with the old header names, must switch to the
new `Camel`-prefixed
names.
+=== 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.
=== camel-debezium - a failed embedded engine is now reported
The Debezium consumers now register a `CompletionCallback` on the embedded
engine. When the engine stops