This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch backport/28296-to-camel-4.22.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit 0690f61929a2d822dfcdbe4265d4e5afd599ab5b Author: Guillaume Nodet <[email protected]> AuthorDate: Fri Oct 23 05:32:55 2026 +0000 [backport camel-4.22.x] CAMEL-25992: camel-pqc - sign and verify message bodies over their raw bytes --- .../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_22.adoc | 16 ++ 6 files changed, 565 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 cba1521c17f0..f7a274e88603 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 @@ -193,7 +193,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 cba1521c17f0..f7a274e88603 100644 --- a/components/camel-pqc/src/main/docs/pqc-component.adoc +++ b/components/camel-pqc/src/main/docs/pqc-component.adoc @@ -193,7 +193,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 ecadab55d4b7..57ca0915a1a9 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; @@ -40,6 +43,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; @@ -584,43 +589,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(); @@ -629,9 +702,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_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index 815dbc57e3dd..72f5e955aef7 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -1325,6 +1325,22 @@ on the bytes of a non-ASCII payload and make verification fail. already UTF-8 (JEP 400), so this is a no-op there; on Java 17 with a non-UTF-8 default, the signature of a non-ASCII `String` payload changes. Binary bodies (`byte[]`, `InputStream`) are unaffected, as they are signed byte-for-byte. + +=== 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-sql - Schema-qualified aggregation repository names The table-name validation in `JdbcAggregationRepository` now accepts schema-qualified names
