This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.18.x by this push:
new 9bee33763631 CAMEL-22114: camel-pqc - convert JDK-native PQC keys to
Bouncy Castle types (4.18.x backport) (#26016)
9bee33763631 is described below
commit 9bee33763631a1c8e0a9df3b51087b3c919bddb1
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Sep 2 09:25:55 2026 +0200
CAMEL-22114: camel-pqc - convert JDK-native PQC keys to Bouncy Castle types
(4.18.x backport) (#26016)
Backport of the fix from main (#25510), hand-adapted: this branch does not
have the hybrid signature operations that surround the change on main, so a
straight cherry-pick would have introduced that feature under a fix. Only
the
key conversion is taken.
On JDK 25.0.4 a JKS KeyStore deserialises standardised PQC keys (ML-DSA,
ML-KEM) into JDK-native key objects that Bouncy Castle's Signature SPI does
not accept, so PQCProducer failed at initSign with
java.security.InvalidKeyException: unknown private key passed to ML-DSA
Re-encoding the pair through BC's KeyFactory converts JDK-native keys into
the
BC types the rest of the component expects, and is a no-op for keys that are
already BC instances.
Adds a private LOG to PQCProducer, since the conversion logs at debug and
this
branch previously relied on DefaultProducer's own private logger. Adds the
assertj-core test dependency, which this module did not declare - other
components on this branch already do.
Verified on Temurin 25.0.4+7, the JDK the CI runner image ships: 114 tests
pass, and with the conversion call disabled PQCSignatureWithKeyStoreTest
fails,
so the change is load-bearing rather than incidental.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---
components/camel-pqc/pom.xml | 5 +
.../apache/camel/component/pqc/PQCProducer.java | 69 ++++++++
.../pqc/PQCKeyStoreJdk25KeyConversionTest.java | 173 +++++++++++++++++++++
3 files changed, 247 insertions(+)
diff --git a/components/camel-pqc/pom.xml b/components/camel-pqc/pom.xml
index e8597d4914b8..9af01f5f4739 100644
--- a/components/camel-pqc/pom.xml
+++ b/components/camel-pqc/pom.xml
@@ -70,6 +70,11 @@
<artifactId>camel-test-junit5</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-crypto</artifactId>
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 1e16d1df6987..1a1346fa59c4 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
@@ -19,6 +19,8 @@ package org.apache.camel.component.pqc;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.cert.Certificate;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
@@ -32,12 +34,18 @@ import org.apache.camel.util.ObjectHelper;
import org.bouncycastle.jcajce.SecretKeyWithEncapsulation;
import org.bouncycastle.jcajce.spec.KEMExtractSpec;
import org.bouncycastle.jcajce.spec.KEMGenerateSpec;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* A Producer which sign or verify a payload
*/
public class PQCProducer extends DefaultProducer {
+ private static final Logger LOG =
LoggerFactory.getLogger(PQCProducer.class);
+
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.
@@ -129,6 +137,15 @@ public class PQCProducer extends DefaultProducer {
} else {
keyPair = getConfiguration().getKeyPair();
}
+
+ // On JDK 25+, a JKS KeyStore (or user-supplied KeyPair) may contain
JDK-native PQC keys
+ // (e.g. ML-DSA, ML-KEM) that Bouncy Castle's Signature / KeyGenerator
SPI does not recognise,
+ // causing InvalidKeyException at initSign / initVerify time.
Re-encoding through BC's KeyFactory
+ // transparently converts JDK-native keys into the BC types the rest
of the component expects,
+ // and is a no-op for keys that are already BC instances.
+ if (keyPair != null) {
+ keyPair = ensureBcKeyPair(keyPair);
+ }
}
private void signature(Exchange exchange)
@@ -235,4 +252,56 @@ public class PQCProducer extends DefaultProducer {
}
}
+ /**
+ * Ensures both keys in the pair are Bouncy Castle key instances.
+ * <p>
+ * On JDK 25+, a JKS {@link KeyStore} may deserialise standardised PQC
keys (ML-DSA, ML-KEM) into JDK-native key
+ * objects that Bouncy Castle's {@link Signature} / {@link KeyGenerator}
SPI does not recognise, causing
+ * {@link InvalidKeyException} at {@code initSign} / {@code initVerify}
time.
+ * <p>
+ * Re-encoding through BC's {@link KeyFactory} is a no-op for keys that
are already BC instances and transparently
+ * converts JDK-native ones into the BC types the rest of the component
expects.
+ */
+ private static KeyPair ensureBcKeyPair(KeyPair kp) {
+ PrivateKey priv = kp.getPrivate();
+ PublicKey pub = kp.getPublic();
+
+ boolean privIsBc = priv == null ||
priv.getClass().getName().startsWith("org.bouncycastle.");
+ boolean pubIsBc = pub == null ||
pub.getClass().getName().startsWith("org.bouncycastle.");
+ if (privIsBc && pubIsBc) {
+ return kp;
+ }
+
+ try {
+ String alg = priv != null ? priv.getAlgorithm() :
pub.getAlgorithm();
+ KeyFactory kf = getBcKeyFactory(alg);
+
+ if (!privIsBc) {
+ priv = kf.generatePrivate(new
PKCS8EncodedKeySpec(priv.getEncoded()));
+ }
+ if (!pubIsBc) {
+ pub = kf.generatePublic(new
X509EncodedKeySpec(pub.getEncoded()));
+ }
+ return new KeyPair(pub, priv);
+ } catch (Exception e) {
+ // If conversion fails (e.g. algorithm not known to BC), return
the original pair
+ // and let the caller deal with any resulting exception from the
crypto operation
+ LOG.debug("Could not convert KeyPair to Bouncy Castle key types:
{}", e.getMessage());
+ return kp;
+ }
+ }
+
+ /**
+ * Returns a BC {@link KeyFactory} for the given JCE algorithm name,
trying the main BC provider first and falling
+ * back to the BC PQC provider.
+ */
+ private static KeyFactory getBcKeyFactory(String algorithm)
+ throws NoSuchAlgorithmException, NoSuchProviderException {
+ try {
+ return KeyFactory.getInstance(algorithm,
BouncyCastleProvider.PROVIDER_NAME);
+ } catch (NoSuchAlgorithmException e) {
+ return KeyFactory.getInstance(algorithm,
BouncyCastlePQCProvider.PROVIDER_NAME);
+ }
+ }
+
}
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCKeyStoreJdk25KeyConversionTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCKeyStoreJdk25KeyConversionTest.java
new file mode 100644
index 000000000000..ad2803ec8f7b
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCKeyStoreJdk25KeyConversionTest.java
@@ -0,0 +1,173 @@
+/*
+ * 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.FileOutputStream;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.*;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Date;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.EndpointInject;
+import org.apache.camel.Produce;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.junit5.CamelTestSupport;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.jcajce.spec.MLDSAParameterSpec;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.OperatorCreationException;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Regression test for the Java 25+ JKS KeyStore key conversion fix.
+ * <p>
+ * On Java 25+, JKS KeyStore deserialises ML-DSA keys as JDK-native objects
(via JEP 497) rather than Bouncy Castle
+ * objects. BC's Signature SPI does not recognise the JDK-native key types and
throws {@link InvalidKeyException
+ * InvalidKeyException: unknown private key passed to ML-DSA}. The fix in
{@code PQCProducer.ensureBcKeyPair()}
+ * re-encodes such keys through BC's {@link KeyFactory} transparently.
+ * <p>
+ * This test is only meaningful on Java 25+ where the JDK provides a native
ML-DSA {@link KeyFactory}. On earlier JVMs,
+ * JKS always returns BC key objects and the conversion is a no-op.
+ */
+@EnabledForJreRange(min = JRE.JAVA_25)
+class PQCKeyStoreJdk25KeyConversionTest extends CamelTestSupport {
+
+ private static final String KEYSTORE_FILE = "keystore-jdk25-test.jks";
+
+ @EndpointInject("mock:sign")
+ protected MockEndpoint resultSign;
+
+ @EndpointInject("mock:verify")
+ protected MockEndpoint resultVerify;
+
+ @Produce("direct:sign")
+ protected ProducerTemplate templateSign;
+
+ PQCKeyStoreJdk25KeyConversionTest() throws NoSuchAlgorithmException {
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:sign")
+
.to("pqc:sign?operation=sign&keyPairAlias=mykey&keyStorePassword=changeit")
+ .to("mock:sign")
+
.to("pqc:verify?operation=verify&keyPairAlias=mykey&keyStorePassword=changeit")
+ .to("mock:verify");
+ }
+ };
+ }
+
+ @BeforeAll
+ static void startup() {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+
+ @AfterAll
+ static void teardown() throws Exception {
+ Files.deleteIfExists(Path.of(KEYSTORE_FILE));
+ }
+
+ /**
+ * Verifies that ML-DSA sign + verify works via a JKS KeyStore on Java
25+, where retrieved keys are JDK-native and
+ * must be converted to BC types by PQCProducer.
+ */
+ @Test
+ void testSignAndVerifyWithJdkNativeKeysFromKeyStore() throws Exception {
+ resultSign.expectedMessageCount(1);
+ resultVerify.expectedMessageCount(1);
+ templateSign.sendBody("Hello from Java 25");
+ resultSign.assertIsSatisfied();
+ resultVerify.assertIsSatisfied();
+
assertThat(resultVerify.getExchanges().get(0).getMessage().getHeader(PQCConstants.VERIFY,
Boolean.class))
+ .as("Signature verification should succeed after JDK-native
key conversion")
+ .isTrue();
+ }
+
+ @BindToRegistry("Keystore")
+ public KeyStore setKeyStore()
+ throws NoSuchAlgorithmException, NoSuchProviderException,
InvalidAlgorithmParameterException, KeyStoreException,
+ CertificateException, IOException, OperatorCreationException,
UnrecoverableKeyException {
+ KeyPairGenerator kpGen =
KeyPairGenerator.getInstance(PQCSignatureAlgorithms.MLDSA.getAlgorithm(),
+ PQCSignatureAlgorithms.MLDSA.getBcProvider());
+ kpGen.initialize(MLDSAParameterSpec.ml_dsa_65);
+ KeyPair kp = kpGen.generateKeyPair();
+
+ // Validity
+ Date startDate = new Date();
+ Date endDate = new Date(startDate.getTime() + 365L * 24 * 60 * 60 *
1000); // 1 year
+
+ // Serial Number
+ BigInteger serialNumber =
BigInteger.valueOf(System.currentTimeMillis());
+
+ X500Name dnName = new X500Name("CN=Test User");
+ // Build the certificate
+ X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
+ dnName,
+ serialNumber,
+ startDate,
+ endDate,
+ dnName,
+ kp.getPublic());
+
+ ContentSigner contentSigner = new
JcaContentSignerBuilder(PQCSignatureAlgorithms.MLDSA.getAlgorithm())
+ .setProvider(PQCSignatureAlgorithms.MLDSA.getBcProvider())
+ .build(kp.getPrivate());
+
+ X509Certificate certificate = new JcaX509CertificateConverter()
+ .setProvider("BC")
+ .getCertificate(certBuilder.build(contentSigner));
+
+ KeyStore keyStore = KeyStore.getInstance("JKS");
+ char[] password = "changeit".toCharArray();
+ keyStore.load(null, password); // initialize new keystore
+ keyStore.setKeyEntry("mykey", kp.getPrivate(), password, new
Certificate[] { certificate });
+
+ // Save keystore to file
+ try (FileOutputStream fos = new FileOutputStream(KEYSTORE_FILE)) {
+ keyStore.store(fos, password);
+ }
+ return keyStore;
+ }
+
+ @BindToRegistry("Signer")
+ public Signature getSigner() throws NoSuchAlgorithmException,
NoSuchProviderException {
+ return
Signature.getInstance(PQCSignatureAlgorithms.MLDSA.getAlgorithm(),
+ PQCSignatureAlgorithms.MLDSA.getBcProvider());
+ }
+}