This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch backport/28296-to-camel-4.18.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit 39d65712bd29add6342bd90027e6bef9b240d0c1 Author: Guillaume Nodet <[email protected]> AuthorDate: Fri Oct 23 05:35:22 2026 +0000 [backport camel-4.18.x] CAMEL-25992: camel-pqc - sign and verify message bodies over their raw bytes --- .../camel-pqc/src/main/docs/pqc-component.adoc | 2 + .../apache/camel/component/pqc/PQCProducer.java | 122 +++++++++- .../component/pqc/PQCBinaryBodySignatureTest.java | 210 ++++++++++++++++++ .../component/pqc/PQCFileBodySignatureTest.java | 247 +++++++++++++++++++++ 4 files changed, 571 insertions(+), 10 deletions(-) diff --git a/components/camel-pqc/src/main/docs/pqc-component.adoc b/components/camel-pqc/src/main/docs/pqc-component.adoc index db4056014ec6..c78492a469bb 100644 --- a/components/camel-pqc/src/main/docs/pqc-component.adoc +++ b/components/camel-pqc/src/main/docs/pqc-component.adoc @@ -99,6 +99,8 @@ In case the KeyPair and the Signature Objects are not in the registry, it will p This will be true for standardized algorithms and for experimental ones. +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 - ML-DSA 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 f5b1098b1e2b..b2378082027c 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,7 +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; @@ -29,6 +34,10 @@ import javax.crypto.spec.SecretKeySpec; import org.apache.camel.Endpoint; import org.apache.camel.Exchange; import org.apache.camel.InvalidPayloadException; +import org.apache.camel.Message; +import org.apache.camel.StreamCache; +import org.apache.camel.TypeConverter; +import org.apache.camel.WrappedFile; import org.apache.camel.support.DefaultProducer; import org.apache.camel.util.ObjectHelper; import org.bouncycastle.jcajce.SecretKeyWithEncapsulation; @@ -46,6 +55,8 @@ public class PQCProducer extends DefaultProducer { private static final Logger LOG = LoggerFactory.getLogger(PQCProducer.class); + private static final int STREAM_BUFFER_SIZE = 8192; + private Signature signer; // Set only when this producer created the Signature itself, so it knows how to create another one. // Left null when the user configured an instance, which then has to be shared and locked instead. @@ -155,15 +166,12 @@ public class PQCProducer extends DefaultProducer { } private void signature(Exchange exchange) - throws InvalidPayloadException, InvalidKeyException, SignatureException, NoSuchAlgorithmException, - NoSuchProviderException { - String payload = exchange.getMessage().getMandatoryBody(String.class); - + throws Exception { Signature signerForExchange = signerForExchange(); byte[] signature; synchronized (signerForExchange) { signerForExchange.initSign(keyPair.getPrivate()); - signerForExchange.update(payload.getBytes(StandardCharsets.UTF_8)); + updateSignatureFromBody(signerForExchange, exchange.getMessage()); signature = signerForExchange.sign(); } exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature); @@ -182,20 +190,114 @@ public class PQCProducer extends DefaultProducer { } private void verification(Exchange exchange) - throws InvalidPayloadException, InvalidKeyException, SignatureException, NoSuchAlgorithmException, - NoSuchProviderException { - String payload = exchange.getMessage().getMandatoryBody(String.class); - + throws InvalidPayloadException, InvalidKeyException, SignatureException, IOException, + NoSuchAlgorithmException, NoSuchProviderException { Signature signerForExchange = signerForExchange(); boolean verified; synchronized (signerForExchange) { signerForExchange.initVerify(keyPair.getPublic()); - signerForExchange.update(payload.getBytes(StandardCharsets.UTF_8)); + updateSignatureFromBody(signerForExchange, exchange.getMessage()); verified = signerForExchange.verify(exchange.getMessage().getHeader(PQCConstants.SIGNATURE, byte[].class)); } exchange.getMessage().setHeader(PQCConstants.VERIFY, verified); } + + /** + * 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 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 = 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 { + updateSignatureFromStream(signature, stream); + } finally { + if (body instanceof StreamCache streamCache) { + streamCache.reset(); + } + } + } else if (body instanceof CharSequence text) { + signature.update(textToBytes(text)); + } else { + 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 KEM {@link KeyGenerator} to use for a single exchange. * <p> 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()); + } +}
