This is an automated email from the ASF dual-hosted git repository.
davsclaus 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 014b1c1b26d1 CAMEL-25992: camel-pqc - sign and verify message bodies
over their raw bytes (#28296)
014b1c1b26d1 is described below
commit 014b1c1b26d1ceadb3166fd559d2a65b2e987ac9
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 23 07:24:35 2026 +0200
CAMEL-25992: camel-pqc - sign and verify message bodies over their raw
bytes (#28296)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../apache/camel/catalog/docs/pqc-component.adoc | 2 +-
.../camel-pqc/src/main/docs/pqc-component.adoc | 2 +-
.../apache/camel/component/pqc/PQCProducer.java | 106 +++++++--
.../component/pqc/PQCBinaryBodySignatureTest.java | 210 ++++++++++++++++++
.../component/pqc/PQCFileBodySignatureTest.java | 247 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 15 ++
6 files changed, 564 insertions(+), 18 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-component.adoc
index 91840a7ae7b4..58b3b4517623 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-component.adoc
@@ -195,7 +195,7 @@ This will be true for standardized algorithms and for
experimental ones.
TIP: To combine a classical signature (ECDSA, Ed25519, RSA) with a PQC
signature for defense-in-depth during the post-quantum transition, use the
hybrid signature operations described in xref:others:pqc-hybrid.adoc[Hybrid
Cryptography].
-NOTE: The sign and verify operations stream the message body. A `byte[]` or
`InputStream` body is fed to the signature in chunks without being materialised
as a String, so large payloads (files, large messages) can be signed and
verified without loading them fully into memory. When the body is a re-readable
`StreamCache` (for example with stream caching enabled) it is reset after
reading so downstream processors still see the payload. Any other body type
falls back to its String represen [...]
+NOTE: The sign and verify operations stream the message body. A `byte[]` or
`InputStream` body is fed to the signature in chunks without being materialised
as a String, so large payloads (files, large messages) can be signed and
verified without loading them fully into memory. A `java.io.File` or
`java.nio.file.Path` body, and a file from a file consumer (a `GenericFile`
body), is read the same way from the file, so the signature covers the bytes of
the file. When the body is a re-readab [...]
== Examples
diff --git a/components/camel-pqc/src/main/docs/pqc-component.adoc
b/components/camel-pqc/src/main/docs/pqc-component.adoc
index 91840a7ae7b4..58b3b4517623 100644
--- a/components/camel-pqc/src/main/docs/pqc-component.adoc
+++ b/components/camel-pqc/src/main/docs/pqc-component.adoc
@@ -195,7 +195,7 @@ This will be true for standardized algorithms and for
experimental ones.
TIP: To combine a classical signature (ECDSA, Ed25519, RSA) with a PQC
signature for defense-in-depth during the post-quantum transition, use the
hybrid signature operations described in xref:others:pqc-hybrid.adoc[Hybrid
Cryptography].
-NOTE: The sign and verify operations stream the message body. A `byte[]` or
`InputStream` body is fed to the signature in chunks without being materialised
as a String, so large payloads (files, large messages) can be signed and
verified without loading them fully into memory. When the body is a re-readable
`StreamCache` (for example with stream caching enabled) it is reset after
reading so downstream processors still see the payload. Any other body type
falls back to its String represen [...]
+NOTE: The sign and verify operations stream the message body. A `byte[]` or
`InputStream` body is fed to the signature in chunks without being materialised
as a String, so large payloads (files, large messages) can be signed and
verified without loading them fully into memory. A `java.io.File` or
`java.nio.file.Path` body, and a file from a file consumer (a `GenericFile`
body), is read the same way from the file, so the signature covers the bytes of
the file. When the body is a re-readab [...]
== Examples
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
index cf34f9333751..1a2e44d06e4c 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
@@ -16,9 +16,12 @@
*/
package org.apache.camel.component.pqc;
+import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.security.*;
import java.security.cert.Certificate;
import java.security.spec.PKCS8EncodedKeySpec;
@@ -41,6 +44,8 @@ import org.apache.camel.InvalidPayloadException;
import org.apache.camel.Message;
import org.apache.camel.RuntimeCamelException;
import org.apache.camel.StreamCache;
+import org.apache.camel.TypeConverter;
+import org.apache.camel.WrappedFile;
import org.apache.camel.component.pqc.crypto.hybrid.HybridKEM;
import org.apache.camel.component.pqc.crypto.hybrid.HybridSignature;
import org.apache.camel.component.pqc.lifecycle.KeyLifecycleManager;
@@ -640,43 +645,111 @@ public class PQCProducer extends DefaultProducer {
/**
* Feeds the message body into the given {@link Signature} without
materialising the whole payload as a String. A
* {@code byte[]} body is used directly, an {@link InputStream} body is
read in chunks (and reset afterwards when it
- * is a re-readable {@link StreamCache} so downstream processors still see
the payload), and any other body type
- * falls back to its String representation, encoded as UTF-8.
+ * is a re-readable {@link StreamCache} so downstream processors still see
the payload), and a file body is read in
+ * chunks from the file (see {@link #fileContent(Object)}). A {@code
String} or {@link CharSequence} body is encoded
+ * as UTF-8, and any other body is read through {@link #rawBytes(Message,
Object)}, which either gets its bytes or
+ * rejects it.
*/
private static void updateSignatureFromBody(Signature signature, Message
message)
throws InvalidPayloadException, SignatureException, IOException {
- Object body = message.getBody();
+ Object body = fileContent(message.getBody());
if (body instanceof byte[] bytes) {
signature.update(bytes);
+ } else if (body instanceof Path path) {
+ try (InputStream stream = Files.newInputStream(path)) {
+ updateSignatureFromStream(signature, stream);
+ }
} else if (body instanceof InputStream stream) {
try {
- byte[] buffer = new byte[STREAM_BUFFER_SIZE];
- int read;
- while ((read = stream.read(buffer)) != -1) {
- signature.update(buffer, 0, read);
- }
+ updateSignatureFromStream(signature, stream);
} finally {
if (body instanceof StreamCache streamCache) {
streamCache.reset();
}
}
+ } else if (body instanceof CharSequence text) {
+ signature.update(textToBytes(text));
} else {
- // Pin UTF-8: the JVM default charset is platform dependent, so
signing and verifying on JVMs with a
- // different default would disagree on the bytes for a non-ASCII
payload
-
signature.update(message.getMandatoryBody(String.class).getBytes(StandardCharsets.UTF_8));
+ signature.update(rawBytes(message, body));
+ }
+ }
+
+ /**
+ * Encodes a text body as UTF-8. The JVM default charset is platform
dependent, so signing and verifying on JVMs
+ * with a different default would disagree on the bytes for a non-ASCII
payload; UTF-8 keeps them in step.
+ */
+ private static byte[] textToBytes(CharSequence text) {
+ return text.toString().getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Returns the bytes of a body that is neither a {@code byte[]}, an {@link
InputStream}, a file nor a
+ * {@link CharSequence}, by asking the type converter registry for a
registered converter to {@code byte[]} - which
+ * covers bodies with a binary form such as a {@link java.nio.ByteBuffer},
a {@link java.io.ByteArrayOutputStream},
+ * a Netty {@code ByteBuf} or a Vert.x {@code Buffer}, so they are signed
over their bytes rather than over a text
+ * rendering. A body with no such converter (for example a {@code Map} or
a POJO) has no well-defined bytes to sign,
+ * so it is rejected with {@link InvalidPayloadException} rather than
signed over an incidental text form. Fallback
+ * converters are not consulted, so the result does not depend on which
components are on the classpath.
+ */
+ private static byte[] rawBytes(Message message, Object body) throws
InvalidPayloadException {
+ Exchange exchange = message.getExchange();
+ if (body != null) {
+ // lookup finds only a registered converter, so a body with a
binary form (ByteBuffer, ByteArrayOutputStream,
+ // Netty ByteBuf, Vert.x Buffer, ...) is converted, while a Map or
POJO - which would only convert through a
+ // fallback converter, if any component provides one - is left for
the InvalidPayloadException below, so the
+ // outcome does not depend on the classpath. tryConvertTo returns
null instead of throwing on a miss.
+ TypeConverter converter =
exchange.getContext().getTypeConverterRegistry().lookup(byte[].class,
body.getClass());
+ if (converter != null) {
+ byte[] bytes = converter.tryConvertTo(byte[].class, exchange,
body);
+ if (bytes != null) {
+ return bytes;
+ }
+ }
+ }
+ throw new InvalidPayloadException(exchange, byte[].class, message);
+ }
+
+ private static void updateSignatureFromStream(Signature signature,
InputStream stream)
+ throws SignatureException, IOException {
+ byte[] buffer = new byte[STREAM_BUFFER_SIZE];
+ int read;
+ while ((read = stream.read(buffer)) != -1) {
+ signature.update(buffer, 0, read);
+ }
+ }
+
+ /**
+ * Resolves a file body to the content to sign: a {@link WrappedFile} (the
body of the file based consumers) gives
+ * its content, and a {@link File} is turned into its {@link Path}, so
that the bytes of the file are read as they
+ * are. The content of a {@link WrappedFile} is a {@link File} for the
local file system, otherwise the content the
+ * consumer has already loaded, such as a {@code byte[]} or an {@link
InputStream}. Other bodies are not changed.
+ */
+ private static Object fileContent(Object body) {
+ if (body instanceof WrappedFile<?> wrappedFile) {
+ Object content = wrappedFile.getBody();
+ if (content != null) {
+ body = content;
+ }
+ }
+ if (body instanceof File file) {
+ return file.toPath();
}
+ return body;
}
/**
* Returns the message body as a byte array without a String round-trip
when it is already binary. Used by the
* hybrid operations, which need the whole payload in memory. An {@link
InputStream} is fully read (and reset
- * afterwards when it is a re-readable {@link StreamCache}). Any other
body type falls back to its String
- * representation, encoded as UTF-8.
+ * afterwards when it is a re-readable {@link StreamCache}), and a file
body is read from the file (see
+ * {@link #fileContent(Object)}). A {@code String} or {@link CharSequence}
body is encoded as UTF-8, and any other
+ * body is read through {@link #rawBytes(Message, Object)}, which either
gets its bytes or rejects it.
*/
private static byte[] bodyToByteArray(Message message) throws
InvalidPayloadException, IOException {
- Object body = message.getBody();
+ Object body = fileContent(message.getBody());
if (body instanceof byte[] bytes) {
return bytes;
+ } else if (body instanceof Path path) {
+ return Files.readAllBytes(path);
} else if (body instanceof InputStream stream) {
try {
return stream.readAllBytes();
@@ -685,9 +758,10 @@ public class PQCProducer extends DefaultProducer {
streamCache.reset();
}
}
+ } else if (body instanceof CharSequence text) {
+ return textToBytes(text);
} else {
- // Pin UTF-8, as updateSignatureFromBody does
- return
message.getMandatoryBody(String.class).getBytes(StandardCharsets.UTF_8);
+ return rawBytes(message, body);
}
}
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCBinaryBodySignatureTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCBinaryBodySignatureTest.java
new file mode 100644
index 000000000000..16c79be472a0
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCBinaryBodySignatureTest.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.component.pqc;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.Security;
+import java.security.Signature;
+import java.security.spec.ECGenParameterSpec;
+import java.util.Map;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.InvalidPayloadException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.pqc.crypto.hybrid.HybridSignature;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.bouncycastle.jcajce.spec.MLDSAParameterSpec;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Bodies that are not a {@code byte[]}, an {@code InputStream} or a file are
signed and verified over their bytes when
+ * they have a binary type ({@link ByteBuffer}, {@link ByteArrayOutputStream},
and, through their own type converters,
+ * Netty {@code ByteBuf} and Vert.x {@code Buffer}), a {@code String} is
signed as UTF-8, and a body with no binary
+ * representation (a {@link Map} or a POJO) is rejected rather than signed
over a text rendering.
+ */
+public class PQCBinaryBodySignatureTest extends CamelTestSupport {
+
+ private static final String PQC_KEY =
"&signatureAlgorithm=MLDSA&keyPair=#pqcKeyPair";
+ private static final String CLASSICAL_KEY =
"&classicalSignatureAlgorithm=ECDSA_P256&classicalKeyPair=#classicalKeyPair";
+ private static final String SIGN = "pqc:sign?operation=sign" + PQC_KEY;
+ private static final String VERIFY = "pqc:verify?operation=verify" +
PQC_KEY;
+
+ private static final byte[] CONTENT = binaryContent();
+
+ private static KeyPair pqcKeyPair;
+ private static KeyPair classicalKeyPair;
+
+ @BeforeAll
+ public static void startup() throws Exception {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ if (Security.getProvider(BouncyCastlePQCProvider.PROVIDER_NAME) ==
null) {
+ Security.addProvider(new BouncyCastlePQCProvider());
+ }
+
+ KeyPairGenerator pqc =
KeyPairGenerator.getInstance(PQCSignatureAlgorithms.MLDSA.getAlgorithm(),
+ PQCSignatureAlgorithms.MLDSA.getBcProvider());
+ pqc.initialize(MLDSAParameterSpec.ml_dsa_65);
+ pqcKeyPair = pqc.generateKeyPair();
+
+ KeyPairGenerator classical
+ =
KeyPairGenerator.getInstance(PQCClassicalSignatureAlgorithms.ECDSA_P256.getKeyAlgorithm());
+ classical.initialize(new
ECGenParameterSpec(PQCClassicalSignatureAlgorithms.ECDSA_P256.getParameterSpec()));
+ classicalKeyPair = classical.generateKeyPair();
+ }
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("pqcKeyPair", pqcKeyPair);
+ registry.bind("classicalKeyPair", classicalKeyPair);
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:sign").to(SIGN);
+ from("direct:verify").to(VERIFY);
+
from("direct:hybridSign").to("pqc:hybridSign?operation=hybridSign" + PQC_KEY +
CLASSICAL_KEY);
+
from("direct:hybridVerify").to("pqc:hybridVerify?operation=hybridVerify" +
PQC_KEY + CLASSICAL_KEY);
+ }
+ };
+ }
+
+ @Test
+ void byteBufferBodyIsSignedOverItsBytes() throws Exception {
+ assertTrue(verifiesContent(sign(ByteBuffer.wrap(CONTENT.clone()))),
+ "the signature must cover the bytes of the ByteBuffer");
+ }
+
+ @Test
+ void byteBufferBodyIsVerifiedOverItsBytes() throws Exception {
+ assertTrue(verify(ByteBuffer.wrap(CONTENT.clone()), signContent()),
+ "a signature over the bytes must verify a ByteBuffer body");
+ }
+
+ @Test
+ void byteArrayOutputStreamBodyIsSignedOverItsBytes() throws Exception {
+ ByteArrayOutputStream body = new ByteArrayOutputStream();
+ body.write(CONTENT);
+
+ assertTrue(verifiesContent(sign(body)), "the signature must cover the
bytes of the ByteArrayOutputStream");
+ }
+
+ @Test
+ void stringBodyIsSignedAsUtf8() throws Exception {
+ String body = "post-quantum café éè";
+
+ Signature verifier = pqcSignature();
+ verifier.initVerify(pqcKeyPair.getPublic());
+ verifier.update(body.getBytes(StandardCharsets.UTF_8));
+ assertTrue(verifier.verify(sign(body)), "a String body must be signed
as UTF-8");
+ }
+
+ @Test
+ void hybridSignatureOfByteBufferBodyCoversItsBytes() throws Exception {
+ Exchange out = request("direct:hybridSign",
ByteBuffer.wrap(CONTENT.clone()), null, null);
+ byte[] hybridSignature =
out.getMessage().getHeader(PQCConstants.HYBRID_SIGNATURE, byte[].class);
+
+ assertNotNull(hybridSignature);
+ assertTrue(HybridSignature.verify(CONTENT, hybridSignature,
classicalKeyPair.getPublic(), classicalSignature(),
+ pqcKeyPair.getPublic(), pqcSignature()), "the hybrid signature
must cover the bytes of the ByteBuffer");
+ }
+
+ @Test
+ void mapBodyIsRejected() {
+ // a Map has no binary representation, so there is no well-defined set
of bytes to sign
+ assertThrows(InvalidPayloadException.class, () ->
sign(Map.of("amount", "10", "currency", "EUR")));
+ }
+
+ @Test
+ void pojoBodyIsRejected() {
+ assertThrows(InvalidPayloadException.class, () -> sign(new
Document("secret.pdf", 10)));
+ }
+
+ private static byte[] binaryContent() {
+ // every byte value, so the content is binary data rather than text
+ byte[] content = new byte[1024];
+ for (int i = 0; i < content.length; i++) {
+ content[i] = (byte) i;
+ }
+ return content;
+ }
+
+ private byte[] sign(Object body) throws Exception {
+ Exchange out = request("direct:sign", body, null, null);
+ return out.getMessage().getHeader(PQCConstants.SIGNATURE,
byte[].class);
+ }
+
+ private boolean verify(Object body, byte[] signature) throws Exception {
+ Exchange out = request("direct:verify", body, PQCConstants.SIGNATURE,
signature);
+ return out.getMessage().getHeader(PQCConstants.VERIFY, Boolean.class);
+ }
+
+ private Exchange request(String uri, Object body, String header, Object
value) throws Exception {
+ Exchange out = template.request(uri, e -> {
+ e.getMessage().setBody(body);
+ if (header != null) {
+ e.getMessage().setHeader(header, value);
+ }
+ });
+ if (out.getException() != null) {
+ throw out.getException();
+ }
+ return out;
+ }
+
+ private static boolean verifiesContent(byte[] signature) throws Exception {
+ Signature verifier = pqcSignature();
+ verifier.initVerify(pqcKeyPair.getPublic());
+ verifier.update(CONTENT);
+ return verifier.verify(signature);
+ }
+
+ private static byte[] signContent() throws Exception {
+ Signature signer = pqcSignature();
+ signer.initSign(pqcKeyPair.getPrivate());
+ signer.update(CONTENT);
+ return signer.sign();
+ }
+
+ private static Signature pqcSignature() throws Exception {
+ return
Signature.getInstance(PQCSignatureAlgorithms.MLDSA.getAlgorithm(),
+ PQCSignatureAlgorithms.MLDSA.getBcProvider());
+ }
+
+ private static Signature classicalSignature() throws Exception {
+ return
Signature.getInstance(PQCClassicalSignatureAlgorithms.ECDSA_P256.getAlgorithm());
+ }
+
+ private record Document(String name, int size) {
+ }
+}
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCFileBodySignatureTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCFileBodySignatureTest.java
new file mode 100644
index 000000000000..80607df87e17
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCFileBodySignatureTest.java
@@ -0,0 +1,247 @@
+/*
+ * 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.pqc;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.Security;
+import java.security.Signature;
+import java.security.spec.ECGenParameterSpec;
+
+import org.apache.camel.EndpointInject;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.file.GenericFile;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.pqc.crypto.hybrid.HybridSignature;
+import org.apache.camel.spi.Registry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.bouncycastle.jcajce.spec.MLDSAParameterSpec;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A {@link File}, a {@link Path} and a file from a file consumer ({@link
GenericFile}) are signed and verified over the
+ * bytes of the file, by the single-algorithm and by the hybrid signature
operations.
+ */
+public class PQCFileBodySignatureTest extends CamelTestSupport {
+
+ private static final String PQC_KEY =
"&signatureAlgorithm=MLDSA&keyPair=#pqcKeyPair";
+ private static final String CLASSICAL_KEY =
"&classicalSignatureAlgorithm=ECDSA_P256&classicalKeyPair=#classicalKeyPair";
+ private static final String SIGN = "pqc:sign?operation=sign" + PQC_KEY;
+ private static final String VERIFY = "pqc:verify?operation=verify" +
PQC_KEY;
+
+ private static final byte[] CONTENT = binaryContent();
+
+ private static KeyPair pqcKeyPair;
+ private static KeyPair classicalKeyPair;
+
+ @TempDir
+ Path folder;
+
+ @EndpointInject("mock:consumed")
+ MockEndpoint consumed;
+
+ @BeforeAll
+ public static void startup() throws Exception {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ if (Security.getProvider(BouncyCastlePQCProvider.PROVIDER_NAME) ==
null) {
+ Security.addProvider(new BouncyCastlePQCProvider());
+ }
+
+ KeyPairGenerator pqc =
KeyPairGenerator.getInstance(PQCSignatureAlgorithms.MLDSA.getAlgorithm(),
+ PQCSignatureAlgorithms.MLDSA.getBcProvider());
+ pqc.initialize(MLDSAParameterSpec.ml_dsa_65);
+ pqcKeyPair = pqc.generateKeyPair();
+
+ KeyPairGenerator classical
+ =
KeyPairGenerator.getInstance(PQCClassicalSignatureAlgorithms.ECDSA_P256.getKeyAlgorithm());
+ classical.initialize(new
ECGenParameterSpec(PQCClassicalSignatureAlgorithms.ECDSA_P256.getParameterSpec()));
+ classicalKeyPair = classical.generateKeyPair();
+ }
+
+ @Override
+ protected void bindToRegistry(Registry registry) {
+ registry.bind("pqcKeyPair", pqcKeyPair);
+ registry.bind("classicalKeyPair", classicalKeyPair);
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:sign").to(SIGN);
+ from("direct:verify").to(VERIFY);
+
from("direct:hybridSign").to("pqc:hybridSign?operation=hybridSign" + PQC_KEY +
CLASSICAL_KEY);
+
from("direct:hybridVerify").to("pqc:hybridVerify?operation=hybridVerify" +
PQC_KEY + CLASSICAL_KEY);
+
+ from("file:" + folder +
"?noop=true&initialDelay=0&delay=10").routeId("fileConsumer").autoStartup(false)
+ .to(SIGN)
+ .to(VERIFY)
+ .to("mock:consumed");
+ }
+ };
+ }
+
+ @Test
+ void fileBodyIsSignedOverItsBytes() throws Exception {
+ File file = write("signed.dat").toFile();
+
+ assertTrue(verifiesContent(sign(file)), "the signature must cover the
bytes of the file");
+ }
+
+ @Test
+ void pathBodyIsSignedOverItsBytes() throws Exception {
+ Path path = write("signed.dat");
+
+ assertTrue(verifiesContent(sign(path)), "the signature must cover the
bytes of the file");
+ }
+
+ @Test
+ void fileBodyIsVerifiedOverItsBytes() throws Exception {
+ File file = write("verified.dat").toFile();
+
+ assertTrue(verify(file, signContent()), "a signature over the bytes of
the file must verify");
+ }
+
+ @Test
+ void pathBodyIsVerifiedOverItsBytes() throws Exception {
+ Path path = write("verified.dat");
+
+ assertTrue(verify(path, signContent()), "a signature over the bytes of
the file must verify");
+ }
+
+ @Test
+ void fileFromFileConsumerIsSignedAndVerifiedOverItsBytes() throws
Exception {
+ write("consumed.dat");
+ consumed.expectedMessageCount(1);
+
+ context.getRouteController().startRoute("fileConsumer");
+
+ consumed.assertIsSatisfied();
+ Exchange exchange = consumed.getReceivedExchanges().get(0);
+ assertTrue(exchange.getMessage().getHeader(PQCConstants.VERIFY,
Boolean.class));
+
assertTrue(verifiesContent(exchange.getMessage().getHeader(PQCConstants.SIGNATURE,
byte[].class)),
+ "the signature must cover the bytes of the consumed file");
+ }
+
+ @Test
+ void genericFileWithLoadedContentIsSignedOverItsBytes() throws Exception {
+ // a file whose content the consumer has already loaded into memory
+ GenericFile<Object> file = new GenericFile<>();
+ file.setFileName("loaded.dat");
+ file.setBody(CONTENT.clone());
+
+ assertTrue(verifiesContent(sign(file)), "the signature must cover the
loaded content of the file");
+ }
+
+ @Test
+ void hybridSignatureOfFileBodyCoversItsBytes() throws Exception {
+ Path path = write("hybrid.dat");
+
+ Exchange out = request("direct:hybridSign", path, null, null);
+ byte[] hybridSignature =
out.getMessage().getHeader(PQCConstants.HYBRID_SIGNATURE, byte[].class);
+
+ assertNotNull(hybridSignature);
+ assertTrue(HybridSignature.verify(CONTENT, hybridSignature,
classicalKeyPair.getPublic(), classicalSignature(),
+ pqcKeyPair.getPublic(), pqcSignature()), "the hybrid signature
must cover the bytes of the file");
+ }
+
+ @Test
+ void hybridVerificationOfFileBodyCoversItsBytes() throws Exception {
+ File file = write("hybrid.dat").toFile();
+ byte[] hybridSignature = HybridSignature.sign(CONTENT,
classicalKeyPair.getPrivate(), classicalSignature(),
+ pqcKeyPair.getPrivate(), pqcSignature());
+
+ Exchange out = request("direct:hybridVerify", file,
PQCConstants.HYBRID_SIGNATURE, hybridSignature);
+
+ assertTrue(out.getMessage().getHeader(PQCConstants.HYBRID_VERIFY,
Boolean.class),
+ "a hybrid signature over the bytes of the file must verify");
+ }
+
+ private static byte[] binaryContent() {
+ // every byte value, so the content is binary data rather than text
+ byte[] content = new byte[1024];
+ for (int i = 0; i < content.length; i++) {
+ content[i] = (byte) i;
+ }
+ return content;
+ }
+
+ private Path write(String name) throws IOException {
+ return Files.write(folder.resolve(name), CONTENT);
+ }
+
+ private byte[] sign(Object body) throws Exception {
+ Exchange out = request("direct:sign", body, null, null);
+ return out.getMessage().getHeader(PQCConstants.SIGNATURE,
byte[].class);
+ }
+
+ private boolean verify(Object body, byte[] signature) throws Exception {
+ Exchange out = request("direct:verify", body, PQCConstants.SIGNATURE,
signature);
+ return out.getMessage().getHeader(PQCConstants.VERIFY, Boolean.class);
+ }
+
+ private Exchange request(String uri, Object body, String header, Object
value) throws Exception {
+ Exchange out = template.request(uri, e -> {
+ e.getMessage().setBody(body);
+ if (header != null) {
+ e.getMessage().setHeader(header, value);
+ }
+ });
+ if (out.getException() != null) {
+ throw out.getException();
+ }
+ return out;
+ }
+
+ private static boolean verifiesContent(byte[] signature) throws Exception {
+ Signature verifier = pqcSignature();
+ verifier.initVerify(pqcKeyPair.getPublic());
+ verifier.update(CONTENT);
+ return verifier.verify(signature);
+ }
+
+ private static byte[] signContent() throws Exception {
+ Signature signer = pqcSignature();
+ signer.initSign(pqcKeyPair.getPrivate());
+ signer.update(CONTENT);
+ return signer.sign();
+ }
+
+ private static Signature pqcSignature() throws Exception {
+ return
Signature.getInstance(PQCSignatureAlgorithms.MLDSA.getAlgorithm(),
+ PQCSignatureAlgorithms.MLDSA.getBcProvider());
+ }
+
+ private static Signature classicalSignature() throws Exception {
+ return
Signature.getInstance(PQCClassicalSignatureAlgorithms.ECDSA_P256.getAlgorithm());
+ }
+}
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 bce2a4b58f32..fedf1ea33b06 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
@@ -2006,6 +2006,21 @@ A started PQC data format no longer keeps a
`KeyGenerator` of its own: `getKeyGe
instance and, unless one is configured, the `CamelPQCKemAlgorithm` header now
selects the KEM algorithm of each
operation.
+=== camel-pqc - message bodies are signed and verified over their bytes
+
+The `sign`, `verify`, `hybridSign` and `hybridVerify` operations now read a
`java.io.File` or `java.nio.file.Path`
+body, and a file from a file consumer (a `GenericFile` body), as bytes from
the file, the same way as a `byte[]` or
+`InputStream` body. A `String` (or any `CharSequence`) body is encoded as
UTF-8, and any other body that has a binary
+type converter (for example a `java.nio.ByteBuffer`, a
`java.io.ByteArrayOutputStream`, a Netty `ByteBuf` or a Vert.x
+`Buffer`) is signed over those bytes.
+
+A body with no binary representation (for example a `Map` or a POJO) is no
longer signed over its `toString()`: it is
+now rejected with an `InvalidPayloadException`. Convert such a body to a
`String`, `byte[]` or other binary type
+earlier in the route.
+
+Signatures created by earlier versions over a `File`, `Path`, `GenericFile` or
other non-`byte[]`/`InputStream` body
+may not verify and need to be created again.
+
=== camel-minio - a listed object that cannot be got is skipped
When the consumer lists the bucket, a failure to stat or get one of the listed
objects (for example access denied, or