diff --git a/components/camel-pqc/src/main/docs/pqc-component.adoc b/components/camel-pqc/src/main/docs/pqc-component.adoc index cd3c119d70f41..d99ebd56f7936 100644 --- a/components/camel-pqc/src/main/docs/pqc-component.adoc +++ b/components/camel-pqc/src/main/docs/pqc-component.adoc @@ -192,7 +192,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. +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 representation, which is always encoded as **UTF-8** so that signing and verifying agree regardless of the JVM default charset. == 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 8e4ef4592e807..3e7552eeaf61e 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 @@ -18,6 +18,7 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.security.*; import java.security.cert.Certificate; import java.util.Arrays; @@ -514,7 +515,7 @@ private void verification(Exchange exchange) * 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. + * falls back to its String representation, encoded as UTF-8. */ private static void updateSignatureFromBody(Signature signature, Message message) throws InvalidPayloadException, SignatureException, IOException { @@ -534,14 +535,17 @@ private static void updateSignatureFromBody(Signature signature, Message message } } } else { - signature.update(message.getMandatoryBody(String.class).getBytes()); + // 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)); } } /** * 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}). + * afterwards when it is a re-readable {@link StreamCache}). Any other body type falls back to its String + * representation, encoded as UTF-8. */ private static byte[] bodyToByteArray(Message message) throws InvalidPayloadException, IOException { Object body = message.getBody(); @@ -556,7 +560,8 @@ private static byte[] bodyToByteArray(Message message) throws InvalidPayloadExce } } } else { - return message.getMandatoryBody(String.class).getBytes(); + // Pin UTF-8, as updateSignatureFromBody does + return message.getMandatoryBody(String.class).getBytes(StandardCharsets.UTF_8); } } diff --git a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCSignatureCharsetTest.java b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCSignatureCharsetTest.java new file mode 100644 index 0000000000000..c0c7ddbdc0f0f --- /dev/null +++ b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCSignatureCharsetTest.java @@ -0,0 +1,95 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.security.Security; +import java.util.Arrays; + +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A String body must always be encoded as UTF-8 when signing and verifying, never with the JVM default charset, so that + * a signature produced on one JVM verifies on another with a different default. + */ +public class PQCSignatureCharsetTest extends CamelTestSupport { + + private static final String NON_ASCII = "héllo wörld — ünïcode"; + + @BeforeAll + public static void startup() { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:signOnly").to("pqc:sign?operation=sign&signatureAlgorithm=MLDSA"); + from("direct:verifyOnly").to("pqc:verify?operation=verify&signatureAlgorithm=MLDSA"); + } + }; + } + + private byte[] sign(Object body) { + Exchange out = template.request("direct:signOnly", e -> e.getMessage().setBody(body)); + return out.getMessage().getHeader(PQCConstants.SIGNATURE, byte[].class); + } + + private boolean verify(Object body, byte[] signature) { + Exchange out = template.request("direct:verifyOnly", e -> { + e.getMessage().setBody(body); + e.getMessage().setHeader(PQCConstants.SIGNATURE, signature); + }); + return out.getMessage().getHeader(PQCConstants.VERIFY, Boolean.class); + } + + @Test + void testStringBodyIsInterchangeableWithItsUtf8Bytes() { + byte[] utf8 = NON_ASCII.getBytes(StandardCharsets.UTF_8); + + byte[] fromString = sign(NON_ASCII); + assertNotNull(fromString); + assertTrue(verify(utf8, fromString), "a String body must be signed as UTF-8"); + + byte[] fromBytes = sign(utf8); + assertTrue(verify(NON_ASCII, fromBytes), "a String body must be verified as UTF-8"); + } + + @Test + void testStringBodyIsNotEncodedWithAnotherCharset() { + byte[] latin1 = NON_ASCII.getBytes(StandardCharsets.ISO_8859_1); + // sanity: the two encodings really do differ for this text + assertFalse(Arrays.equals(latin1, NON_ASCII.getBytes(StandardCharsets.UTF_8))); + + // so a signature over the String must not verify against the ISO-8859-1 bytes + assertFalse(verify(latin1, sign(NON_ASCII)), + "a String body must not be encoded with the platform charset (ISO-8859-1 here)"); + } +} 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 80753018c84af..dde8b9d61593f 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 @@ -481,6 +481,17 @@ already required by `JdbcAggregationRepository` for reading and updating aggrega `recoveryByInstance` is enabled, the completed table must have the `instance_id VARCHAR(255)` column as documented. +=== camel-pqc - String payloads are signed and verified as UTF-8 + +The `sign`, `verify`, `hybridSign` and `hybridVerify` operations previously encoded a `String` +message body using the JVM default charset (`String.getBytes()`). The default charset is platform +dependent, so signing on one JVM and verifying on another with a different default could disagree +on the bytes of a non-ASCII payload and make verification fail. + +`String` bodies are now always encoded as UTF-8. On Java 18 and newer the default charset is +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-sql - Schema-qualified aggregation repository names The table-name validation in `JdbcAggregationRepository` now accepts schema-qualified names