From 3ecfaa2b943d84ea475de31cef5147f7683a2c6b Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Wed, 15 Jul 2026 13:11:07 +0200 Subject: [PATCH] CAMEL-23843: Camel-PQC sign and verify String payloads as UTF-8 sign/verify (and the hybrid variants) encoded a String message body with String.getBytes(), which uses the JVM default charset. 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 fail verification. Pin StandardCharsets.UTF_8 in both body-to-bytes fallbacks, so signing and verifying always agree. Binary bodies (byte[], InputStream) are unaffected: they are already signed byte-for-byte (CAMEL-23847). 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 it changes the signature of a non-ASCII String payload, which is documented in the 4.22 upgrade guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/docs/pqc-component.adoc | 2 +- .../camel/component/pqc/PQCProducer.java | 13 ++- .../pqc/PQCSignatureCharsetTest.java | 95 +++++++++++++++++++ .../pages/camel-4x-upgrade-guide-4_22.adoc | 12 +++ 4 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCSignatureCharsetTest.java 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 920911b999e0d..912a5729a95c0 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; @@ -509,7 +510,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 { @@ -529,14 +530,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(); @@ -551,7 +555,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 0454ac8a835a2..285e6f4e1ea2d 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 @@ -377,3 +377,15 @@ The aggregation tables used with these repositories must have the `version BIGIN already required by `JdbcAggregationRepository` for reading and updating aggregates. When `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.