This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new a80092bc631f CAMEL-25824: camel-pqc - the hybrid KEM operations stamp
the JCE algorithm name on the secret key (#29220)
a80092bc631f is described below
commit a80092bc631fc7a35062f89a1d89570d490a5327
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Nov 5 16:47:47 2026 +0100
CAMEL-25824: camel-pqc - the hybrid KEM operations stamp the JCE algorithm
name on the secret key (#29220)
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
---
.../component/pqc/PQCSymmetricAlgorithms.java | 23 ++++
.../component/pqc/crypto/hybrid/HybridKEM.java | 8 +-
.../component/pqc/dataformat/PQCDataFormat.java | 18 +--
.../pqc/crypto/hybrid/HybridKEMKeyLabelTest.java | 137 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 11 ++
5 files changed, 180 insertions(+), 17 deletions(-)
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCSymmetricAlgorithms.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCSymmetricAlgorithms.java
index 03bfad6b4ba2..bccd5b1e6303 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCSymmetricAlgorithms.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCSymmetricAlgorithms.java
@@ -48,4 +48,27 @@ public enum PQCSymmetricAlgorithms {
return algorithm;
}
+ /**
+ * Maps a configured symmetric algorithm name to the JCE name a {@link
javax.crypto.Cipher} resolves.
+ * <p/>
+ * Two constants spell the two differently - {@code GOST3412_2015} is
{@code GOST3412-2015} and {@code DESEDE} is
+ * {@code DESede} - so a key labelled with the constant name cannot be
handed to {@code Cipher.getInstance}. A name
+ * that is not one of the constants, which includes a JCE name that has
already been mapped, is answered unchanged
+ * rather than rejected, so this is safe to apply to a value of unknown
provenance.
+ *
+ * @param name the configured name, or a JCE name
+ * @return the JCE name, or {@code name} when it is not one of the
constants
+ */
+ public static String toJceName(String name) {
+ if (name == null) {
+ return null;
+ }
+ for (PQCSymmetricAlgorithms candidate : values()) {
+ if (candidate.name().equals(name)) {
+ return candidate.getAlgorithm();
+ }
+ }
+ return name;
+ }
+
}
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/crypto/hybrid/HybridKEM.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/crypto/hybrid/HybridKEM.java
index 16493c36a476..76330529fd82 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/crypto/hybrid/HybridKEM.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/crypto/hybrid/HybridKEM.java
@@ -29,6 +29,7 @@ import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
+import org.apache.camel.component.pqc.PQCSymmetricAlgorithms;
import org.apache.camel.util.SecureRandomHelper;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.crypto.digests.SHA384Digest;
@@ -123,7 +124,9 @@ public final class HybridKEM {
keyLength / 8,
symmetricAlgorithm);
- SecretKey hybridSecretKey = new SecretKeySpec(combinedSecret,
symmetricAlgorithm);
+ // The label only: the value handed to combineSecrets above is the
HKDF info and must stay exactly as the
+ // caller gave it, or every encapsulation produced by an earlier
version stops decrypting.
+ SecretKey hybridSecretKey = new SecretKeySpec(combinedSecret,
PQCSymmetricAlgorithms.toJceName(symmetricAlgorithm));
// Combine encapsulations
byte[] hybridEncapsulation =
combineEncapsulations(classicalEncapsulation, pqcEncapsulation);
@@ -199,7 +202,8 @@ public final class HybridKEM {
keyLength / 8,
symmetricAlgorithm);
- return new SecretKeySpec(combinedSecret, symmetricAlgorithm);
+ // As in encapsulate(): the label is mapped, the HKDF info is not
+ return new SecretKeySpec(combinedSecret,
PQCSymmetricAlgorithms.toJceName(symmetricAlgorithm));
}
/**
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/dataformat/PQCDataFormat.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/dataformat/PQCDataFormat.java
index b36aae6941a4..e0a5a4d482a0 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/dataformat/PQCDataFormat.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/dataformat/PQCDataFormat.java
@@ -266,7 +266,7 @@ public class PQCDataFormat extends ServiceSupport
implements DataFormat, DataFor
// Fail fast if the configured symmetric algorithm cannot provide
authenticated encryption. Per-message
// overrides via the CamelPQCSymmetricAlgorithm header are validated
at marshal/unmarshal time.
if (symmetricKeyAlgorithm != null) {
- checkAeadSupported(toJceAlgorithm(symmetricKeyAlgorithm));
+
checkAeadSupported(PQCSymmetricAlgorithms.toJceName(symmetricKeyAlgorithm));
}
if (keyEncapsulationAlgorithm != null && keyGenerator == null) {
@@ -335,21 +335,9 @@ public class PQCDataFormat extends ServiceSupport
implements DataFormat, DataFor
private String getSymmetricAlgorithm(Exchange exchange) {
String alg = exchange.getIn().getHeader(SYMMETRIC_ALGORITHM,
String.class);
if (alg != null) {
- return toJceAlgorithm(alg);
- }
- return toJceAlgorithm(this.symmetricKeyAlgorithm);
- }
-
- /**
- * Maps a {@link PQCSymmetricAlgorithms} enum name to its JCE algorithm
name, returning the input unchanged when it
- * is not an enum constant (e.g. it is already a JCE name).
- */
- private static String toJceAlgorithm(String algorithm) {
- try {
- return PQCSymmetricAlgorithms.valueOf(algorithm).getAlgorithm();
- } catch (IllegalArgumentException e) {
- return algorithm;
+ return PQCSymmetricAlgorithms.toJceName(alg);
}
+ return PQCSymmetricAlgorithms.toJceName(this.symmetricKeyAlgorithm);
}
/**
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/crypto/hybrid/HybridKEMKeyLabelTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/crypto/hybrid/HybridKEMKeyLabelTest.java
new file mode 100644
index 000000000000..50541877c810
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/crypto/hybrid/HybridKEMKeyLabelTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.crypto.hybrid;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.util.Arrays;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyAgreement;
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+
+import org.apache.camel.component.pqc.PQCSymmetricAlgorithms;
+import org.bouncycastle.jcajce.spec.MLKEMParameterSpec;
+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.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/**
+ * The hybrid KEM must stamp the JCE algorithm name on the secret key it
returns, as the non-hybrid operations do, so
+ * both ends of an exchange agree on the key's identity and the key resolves
through {@code Cipher.getInstance}.
+ * <p/>
+ * The same configured name is also the HKDF info of {@code combineSecrets},
which must keep its original value: a
+ * change there would derive a different key and make every encapsulation
produced by an earlier version undecryptable.
+ */
+class HybridKEMKeyLabelTest {
+
+ @BeforeAll
+ static void setup() {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ if (Security.getProvider(BouncyCastlePQCProvider.PROVIDER_NAME) ==
null) {
+ Security.addProvider(new BouncyCastlePQCProvider());
+ }
+ }
+
+ private static KeyPair x25519() throws Exception {
+ return KeyPairGenerator.getInstance("X25519", "BC").generateKeyPair();
+ }
+
+ private static KeyPair mlkem() throws Exception {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-KEM", "BC");
+ kpg.initialize(MLKEMParameterSpec.ml_kem_768, new SecureRandom());
+ return kpg.generateKeyPair();
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "AES,AES,256",
+ // the two whose configured name is not the JCE name
+ "DESEDE,DESede,192",
+ "GOST3412_2015,GOST3412-2015,256" })
+ void bothEndsStampTheJceName(String configured, String jceName, int
keyLength) throws Exception {
+ KeyPair classical = x25519();
+ KeyPair pqc = mlkem();
+
+ HybridKEM.HybridEncapsulationResult encapsulated =
HybridKEM.encapsulate(
+ classical.getPublic(), pqc.getPublic(),
+ KeyAgreement.getInstance("X25519", "BC"),
KeyGenerator.getInstance("ML-KEM", "BC"),
+ configured, keyLength, "HKDF-SHA256");
+
+ SecretKey extracted = HybridKEM.extract(
+ encapsulated.encapsulation(), classical.getPrivate(),
pqc.getPrivate(),
+ KeyAgreement.getInstance("X25519", "BC"),
KeyGenerator.getInstance("ML-KEM", "BC"),
+ configured, keyLength, "HKDF-SHA256");
+
+ assertEquals(jceName, encapsulated.sharedSecret().getAlgorithm(),
+ "the generated key must carry the JCE name");
+ assertEquals(jceName, extracted.getAlgorithm(), "the extracted key
must carry the JCE name");
+ assertArrayEquals(encapsulated.sharedSecret().getEncoded(),
extracted.getEncoded(),
+ "both ends must still derive the same secret");
+
+ // the point of the JCE name: the key is directly usable
+ assertDoesNotThrow(() -> Cipher.getInstance(extracted.getAlgorithm()),
+ "the stamped name must resolve as a cipher algorithm");
+ }
+
+ @Test
+ void theDerivedSecretIsUnchangedByTheRelabelling() throws Exception {
+ KeyPair classical = x25519();
+ KeyPair pqc = mlkem();
+
+ // The HKDF info is the configured name, so deriving with the
configured name and with its JCE name must
+ // produce different secrets. If they matched, this test could not
tell whether the info had been remapped.
+ HybridKEM.HybridEncapsulationResult viaConfigured =
HybridKEM.encapsulate(
+ classical.getPublic(), pqc.getPublic(),
+ KeyAgreement.getInstance("X25519", "BC"),
KeyGenerator.getInstance("ML-KEM", "BC"),
+ "DESEDE", 192, "HKDF-SHA256");
+
+ // Extracting the same encapsulation with the JCE spelling must fail
to reproduce the secret, which is what
+ // shows the info still carries the configured name rather than the
mapped one.
+ SecretKey viaJceName = HybridKEM.extract(
+ viaConfigured.encapsulation(), classical.getPrivate(),
pqc.getPrivate(),
+ KeyAgreement.getInstance("X25519", "BC"),
KeyGenerator.getInstance("ML-KEM", "BC"),
+ "DESede", 192, "HKDF-SHA256");
+
+ assertFalse(
+ Arrays.equals(viaConfigured.sharedSecret().getEncoded(),
viaJceName.getEncoded()),
+ "the HKDF info must still be the configured name: if remapping
it made these equal, the wire format"
+
+ " would have changed and older encapsulations would no
longer decrypt");
+ }
+
+ @Test
+ void anAlreadyJceNameIsLeftAlone() {
+ assertEquals("AES", PQCSymmetricAlgorithms.toJceName("AES"));
+ assertEquals("DESede", PQCSymmetricAlgorithms.toJceName("DESEDE"));
+ assertEquals("DESede", PQCSymmetricAlgorithms.toJceName("DESede"));
+ assertEquals("GOST3412-2015",
PQCSymmetricAlgorithms.toJceName("GOST3412-2015"));
+ assertEquals("Unknown", PQCSymmetricAlgorithms.toJceName("Unknown"));
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index ed9b9921607e..0865882254a9 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1818,6 +1818,17 @@ be updated.
`Symmetric Algorithm needs to be specified`, as the extract operations already
did, instead of failing later inside
BouncyCastle with `Missing argument`.
+=== camel-pqc - the hybrid KEM operations stamp the JCE algorithm name too
+
+`hybridGenerateSecretKeyEncapsulation` and
`hybridExtractSecretKeyEncapsulation` label the `SecretKey` they produce
+with the mapped JCE algorithm name, as the non-hybrid operations do. `DESEDE`
now yields `DESede` and
+`GOST3412_2015` now yields `GOST3412-2015`; every other algorithm is
unaffected, since its configured name and its
+JCE name are the same.
+
+Only the label changes. The derived key itself is unchanged: the configured
name is also the HKDF info the two
+shared secrets are combined with, and that keeps its original value, so an
encapsulation produced by an earlier
+version still decrypts.
+
=== camel-jbang - camel ask asks before a call that changes something, reaches
out or runs code
`camel ask` now asks on the terminal before a file write (showing the diff), a
message to an endpoint the