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

Reply via email to