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 79f45b6a04d8 CAMEL-25801: camel-pqc - align the KeyLifecycleManager
implementations (#28105)
79f45b6a04d8 is described below
commit 79f45b6a04d8ff3eb6a1f94f28d189158e076198
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Oct 21 10:27:04 2026 +0200
CAMEL-25801: camel-pqc - align the KeyLifecycleManager implementations
(#28105)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---
.../apache/camel/component/pqc/PQCProducer.java | 8 +-
.../AwsSecretsManagerKeyLifecycleManager.java | 15 +-
.../AzureKeyVaultKeyLifecycleManager.java | 14 +-
.../lifecycle/FileBasedKeyLifecycleManager.java | 36 ++++-
.../HashicorpVaultKeyLifecycleManager.java | 21 ++-
.../pqc/lifecycle/KeyFormatConverter.java | 99 ++++++++++---
.../camel/component/pqc/PQCDeleteKeyStateTest.java | 114 +++++++++++++++
.../pqc/lifecycle/KeyLifecycleParityTest.java | 157 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 27 ++++
9 files changed, 450 insertions(+), 41 deletions(-)
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 10b2af9702e2..9b24ae434c30 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
@@ -1101,10 +1101,10 @@ public class PQCProducer extends DefaultProducer {
throw new IllegalArgumentException("Key ID header (" +
PQCConstants.KEY_ID + ") is required for deleteKeyState");
}
- KeyLifecycleManager klm = getConfiguration().getKeyLifecycleManager();
- if (klm != null) {
- klm.deleteKey(keyId);
- }
+ // Required, as for every other lifecycle operation. Reporting success
for a stateful key state that was
+ // never deleted is the worst available outcome: the caller believes
the exhausted or compromised state is
+ // gone and may reuse the key.
+ getRequiredKeyLifecycleManager().deleteKey(keyId);
}
/**
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AwsSecretsManagerKeyLifecycleManager.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AwsSecretsManagerKeyLifecycleManager.java
index 76ef0f38ce4a..35bc7e7d6876 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AwsSecretsManagerKeyLifecycleManager.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AwsSecretsManagerKeyLifecycleManager.java
@@ -366,13 +366,18 @@ public class AwsSecretsManagerKeyLifecycleManager
implements KeyLifecycleManager
}
}
+ /**
+ * Writes the metadata secret only.
+ * <p/>
+ * Re-storing the key pair here would create a new version of the private
key secret on every status change, and
+ * would make revoking a key whose private secret is already gone
impossible - which is exactly when revoking
+ * matters.
+ */
@Override
public void updateKeyMetadata(String keyId, KeyMetadata metadata) throws
Exception {
- // Read existing key pair
- KeyPair keyPair = getKey(keyId);
-
- // Store updated metadata with existing key pair
- storeKey(keyId, keyPair, metadata);
+ createOrUpdateSecret(getSecretName(keyId, "metadata"),
KeyMetadataCodec.toJson(metadata),
+ "PQC Key Metadata: " + keyId);
+ metadataCache.put(keyId, metadata);
}
@Override
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AzureKeyVaultKeyLifecycleManager.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AzureKeyVaultKeyLifecycleManager.java
index 239a43322d41..afa1abda995c 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AzureKeyVaultKeyLifecycleManager.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AzureKeyVaultKeyLifecycleManager.java
@@ -293,13 +293,17 @@ public class AzureKeyVaultKeyLifecycleManager implements
KeyLifecycleManager {
}
}
+ /**
+ * Writes the metadata secret only.
+ * <p/>
+ * Re-storing the key pair here would create a new version of the private
key secret on every status change, and
+ * would make revoking a key whose private secret is already gone
impossible - which is exactly when revoking
+ * matters.
+ */
@Override
public void updateKeyMetadata(String keyId, KeyMetadata metadata) throws
Exception {
- // Read existing key pair
- KeyPair keyPair = getKey(keyId);
-
- // Store updated metadata with existing key pair
- storeKey(keyId, keyPair, metadata);
+ secretClient.setSecret(getSecretName(keyId, "metadata"),
KeyMetadataCodec.toJson(metadata));
+ metadataCache.put(keyId, metadata);
}
@Override
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManager.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManager.java
index 8d4b43f67e2b..b7db7b6be41a 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManager.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManager.java
@@ -60,6 +60,7 @@ import org.slf4j.LoggerFactory;
public class FileBasedKeyLifecycleManager implements KeyLifecycleManager {
private static final Logger LOG =
LoggerFactory.getLogger(FileBasedKeyLifecycleManager.class);
+ private static final String METADATA_SUFFIX = ".metadata";
private final Path keyDirectory;
private final ObjectMapper objectMapper;
@@ -253,9 +254,36 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
LOG.info("Deleted key: {}", keyId);
}
+ /**
+ * Lists the keys by scanning the key directory for metadata files.
+ * <p/>
+ * Returning the cache alone would hide any key file written after
start-up - by another process, by an operator
+ * restoring a backup, or by a sidecar. That matters directly for {@link
KeyRotationScheduler}, which drives
+ * entirely off this method and would otherwise stop rotating anything it
had not seen at boot. Note that the scan
+ * finds such a key, but the metadata of a key this process already knows
still comes from the cache, so a status
+ * another process wrote for it is not picked up until a restart.
+ * <p/>
+ * A file that cannot be read is logged and skipped rather than failing
the call: one corrupt or half-written
+ * metadata file must not stop every other key from being listed, and so
rotated.
+ */
@Override
public List<KeyMetadata> listKeys() throws Exception {
- return new ArrayList<>(metadataCache.values());
+ List<KeyMetadata> keys = new ArrayList<>();
+ try (Stream<Path> files = Files.list(keyDirectory)) {
+ for (Path file : files.filter(path ->
path.toString().endsWith(METADATA_SUFFIX)).sorted().toList()) {
+ String name = file.getFileName().toString();
+ String keyId = name.substring(0, name.length() -
METADATA_SUFFIX.length());
+ try {
+ KeyMetadata metadata = getKeyMetadata(keyId);
+ if (metadata != null) {
+ keys.add(metadata);
+ }
+ } catch (Exception e) {
+ LOG.warn("Failed to read key metadata, skipping: {}",
file, e);
+ }
+ }
+ }
+ return keys;
}
@Override
@@ -383,10 +411,10 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
private void loadExistingKeys() {
try (Stream<Path> files = Files.list(keyDirectory)) {
- files.filter(path -> path.toString().endsWith(".metadata"))
+ files.filter(path -> path.toString().endsWith(METADATA_SUFFIX))
.forEach(path -> {
try {
- String keyId =
path.getFileName().toString().replace(".metadata", "");
+ String keyId =
path.getFileName().toString().replace(METADATA_SUFFIX, "");
KeyMetadata metadata = getKeyMetadata(keyId);
if (metadata != null) {
LOG.debug("Loaded existing key: {}", metadata);
@@ -450,7 +478,7 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
}
private Path getMetadataFile(String keyId) {
- return resolveKeyFile(keyId, ".metadata");
+ return resolveKeyFile(keyId, METADATA_SUFFIX);
}
private Path getLegacyKeyFile(String keyId) {
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/HashicorpVaultKeyLifecycleManager.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/HashicorpVaultKeyLifecycleManager.java
index 46e5cfd19b22..30fa3379e668 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/HashicorpVaultKeyLifecycleManager.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/HashicorpVaultKeyLifecycleManager.java
@@ -406,13 +406,26 @@ public class HashicorpVaultKeyLifecycleManager implements
KeyLifecycleManager {
return metadata;
}
+ /**
+ * Writes the metadata entry only.
+ * <p/>
+ * Re-storing the key pair here would create a new version of the private
key entry on every status change - with
+ * the default {@code max_versions = 10} of a KV v2 mount, ten metadata
edits purge every historical version of the
+ * private key. It would also make revoking a key whose private entry is
already gone impossible, which is exactly
+ * when revoking matters.
+ */
@Override
public void updateKeyMetadata(String keyId, KeyMetadata metadata) throws
Exception {
- // Read existing key pair
- KeyPair keyPair = getKey(keyId);
+ VaultKeyValueOperations keyValue =
vaultTemplate.opsForKeyValue(secretsEngine,
+ VaultKeyValueOperationsSupport.KeyValueBackend.versioned());
- // Store updated metadata with existing key pair
- storeKey(keyId, keyPair, metadata);
+ Map<String, Object> metadataData = new HashMap<>();
+ metadataData.put("metadata", KeyMetadataCodec.toJson(metadata));
+ metadataData.put("keyId", keyId);
+ metadataData.put("algorithm", metadata.getAlgorithm());
+ keyValue.put(getKeyPath(keyId) + "/metadata", metadataData);
+
+ metadataCache.put(keyId, metadata);
}
@Override
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyFormatConverter.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyFormatConverter.java
index b996197f5077..b9c15928b307 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyFormatConverter.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyFormatConverter.java
@@ -16,14 +16,19 @@
*/
package org.apache.camel.component.pqc.lifecycle;
+import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
+import java.security.Security;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
+import org.apache.camel.component.pqc.PQCKeyEncapsulationAlgorithms;
+import org.apache.camel.component.pqc.PQCSignatureAlgorithms;
+
/**
* Utility class for converting PQC keys between different formats (PEM, DER,
PKCS8, X509).
*/
@@ -71,7 +76,9 @@ public class KeyFormatConverter {
// Concatenate with separator for PEM
if (format == KeyLifecycleManager.KeyFormat.PEM) {
- return (new String(publicKeyBytes) + "\n" + new
String(privateKeyBytes)).getBytes();
+ return (new String(publicKeyBytes, StandardCharsets.UTF_8) +
"\n"
+ + new String(privateKeyBytes, StandardCharsets.UTF_8))
+ .getBytes(StandardCharsets.UTF_8);
} else {
// For DER, just return private key (contains public key info)
return privateKeyBytes;
@@ -95,8 +102,7 @@ public class KeyFormatConverter {
}
X509EncodedKeySpec spec = new X509EncodedKeySpec(derBytes);
- KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
- return keyFactory.generatePublic(spec);
+ return keyFactory(algorithm).generatePublic(spec);
}
/**
@@ -113,8 +119,45 @@ public class KeyFormatConverter {
}
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(derBytes);
- KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
- return keyFactory.generatePrivate(spec);
+ return keyFactory(algorithm).generatePrivate(spec);
+ }
+
+ /**
+ * Resolves the {@link KeyFactory} of an algorithm with the BouncyCastle
provider that serves it, so an imported key
+ * is of the same provider as the keys this component generates and signs
with. From JDK 24 the JDK itself
+ * implements ML-DSA and ML-KEM and is listed before BouncyCastle, so a
plain {@code KeyFactory.getInstance} returns
+ * a JDK key, which the component's BouncyCastle {@code Signature} rejects
as an unknown private key. An algorithm
+ * the component does not map, or whose provider is not installed, keeps
the default lookup.
+ */
+ private static KeyFactory keyFactory(String algorithm) throws Exception {
+ String provider = bouncyCastleProviderFor(algorithm);
+ if (provider != null && Security.getProvider(provider) != null) {
+ return KeyFactory.getInstance(algorithm, provider);
+ }
+ return KeyFactory.getInstance(algorithm);
+ }
+
+ /**
+ * The BouncyCastle provider ({@code BC} or {@code BCPQC}) serving an
algorithm given by the component's name
+ * ({@code MLDSA}) or by its JCE name ({@code ML-DSA}), or {@code null}
for an algorithm the component does not
+ * know.
+ */
+ private static String bouncyCastleProviderFor(String algorithm) {
+ String provider = KeyAlgorithmSupport.determineProvider(algorithm);
+ if (provider != null) {
+ return provider;
+ }
+ for (PQCSignatureAlgorithms candidate :
PQCSignatureAlgorithms.values()) {
+ if (candidate.getAlgorithm().equalsIgnoreCase(algorithm)) {
+ return candidate.getBcProvider();
+ }
+ }
+ for (PQCKeyEncapsulationAlgorithms candidate :
PQCKeyEncapsulationAlgorithms.values()) {
+ if (candidate.getAlgorithm().equalsIgnoreCase(algorithm)) {
+ return candidate.getBcProvider();
+ }
+ }
+ return null;
}
/**
@@ -131,7 +174,7 @@ public class KeyFormatConverter {
pem.append(base64, i, Math.min(i + 64,
base64.length())).append("\n");
}
pem.append("-----END PUBLIC KEY-----\n");
- return pem.toString().getBytes();
+ return pem.toString().getBytes(StandardCharsets.UTF_8);
}
/**
@@ -148,28 +191,46 @@ public class KeyFormatConverter {
pem.append(base64, i, Math.min(i + 64,
base64.length())).append("\n");
}
pem.append("-----END PRIVATE KEY-----\n");
- return pem.toString().getBytes();
+ return pem.toString().getBytes(StandardCharsets.UTF_8);
}
/**
* Parse PEM-encoded public key
*/
- private static byte[] parsePEMPublicKey(byte[] pemData) throws Exception {
- String pemString = new String(pemData);
- pemString = pemString.replace("-----BEGIN PUBLIC KEY-----", "")
- .replace("-----END PUBLIC KEY-----", "")
- .replaceAll("\\s", "");
- return Base64.getDecoder().decode(pemString);
+ private static byte[] parsePEMPublicKey(byte[] pemData) {
+ return parsePEMBlock(pemData, "PUBLIC KEY");
}
/**
* Parse PEM-encoded private key
*/
- private static byte[] parsePEMPrivateKey(byte[] pemData) throws Exception {
- String pemString = new String(pemData);
- pemString = pemString.replace("-----BEGIN PRIVATE KEY-----", "")
- .replace("-----END PRIVATE KEY-----", "")
- .replaceAll("\\s", "");
- return Base64.getDecoder().decode(pemString);
+ private static byte[] parsePEMPrivateKey(byte[] pemData) {
+ return parsePEMBlock(pemData, "PRIVATE KEY");
+ }
+
+ /**
+ * Extracts the body of one labelled PEM block.
+ * <p/>
+ * The block is located by its BEGIN and END lines rather than by
stripping those lines out of the whole input, so
+ * that a document holding more than one block - as {@link #exportKeyPair}
produces for a key pair including the
+ * private key - yields the requested block instead of a Base64 string
still carrying the armour of the other one.
+ */
+ private static byte[] parsePEMBlock(byte[] pemData, String label) {
+ String pem = new String(pemData, StandardCharsets.UTF_8);
+ String begin = "-----BEGIN " + label + "-----";
+ String end = "-----END " + label + "-----";
+
+ int from = pem.indexOf(begin);
+ if (from < 0) {
+ throw new IllegalArgumentException("PEM data holds no '" + begin +
"' line");
+ }
+ from += begin.length();
+
+ int to = pem.indexOf(end, from);
+ if (to < 0) {
+ throw new IllegalArgumentException("PEM data holds no '" + end +
"' line");
+ }
+
+ return Base64.getDecoder().decode(pem.substring(from,
to).replaceAll("\\s", ""));
}
}
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCDeleteKeyStateTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCDeleteKeyStateTest.java
new file mode 100644
index 000000000000..043048678a03
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCDeleteKeyStateTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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.security.Security;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.Produce;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.pqc.lifecycle.InMemoryKeyLifecycleManager;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * deleteKeyState must not report success when nothing was deleted: for a
stateful key the caller would believe the
+ * exhausted state is gone and could reuse the key.
+ */
+public class PQCDeleteKeyStateTest extends CamelTestSupport {
+
+ private final InMemoryKeyLifecycleManager keyManager = new
InMemoryKeyLifecycleManager();
+
+ @Produce("direct:withManager")
+ protected ProducerTemplate withManager;
+
+ @Produce("direct:withoutManager")
+ protected ProducerTemplate withoutManager;
+
+ @BeforeEach
+ void addProviders() {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ if (Security.getProvider(BouncyCastlePQCProvider.PROVIDER_NAME) ==
null) {
+ Security.addProvider(new BouncyCastlePQCProvider());
+ }
+ }
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ // keyLifecycleManager is autowired, so the registry bean would also
reach the endpoint that is meant to
+ // have none, and the branch under test would never run
+ context.setAutowiredEnabled(false);
+ return context;
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ context.getRegistry().bind("keyManager", keyManager);
+ from("direct:withManager")
+
.to("pqc:a?operation=deleteKeyState&signatureAlgorithm=MLDSA&keyLifecycleManager=#keyManager");
+ from("direct:withoutManager")
+
.to("pqc:b?operation=deleteKeyState&signatureAlgorithm=MLDSA");
+ }
+ };
+ }
+
+ @Test
+ void deletingWithoutAKeyLifecycleManagerFailsLoudly() {
+ CamelExecutionException e = assertThrows(CamelExecutionException.class,
+ () ->
withoutManager.sendBodyAndHeader("direct:withoutManager", "x",
PQCConstants.KEY_ID, "k1"));
+
+ Throwable cause = e.getCause();
+ assertInstanceOf(IllegalStateException.class, cause, "expected the
same error the other lifecycle operations"
+ + " raise, but
got: " + cause);
+ assertTrue(cause.getMessage().contains("KeyLifecycleManager"),
cause.getMessage());
+ }
+
+ @Test
+ void deletingWithAManagerRemovesTheKey() throws Exception {
+ keyManager.generateKeyPair("MLDSA", "k1");
+ assertNotNull(keyManager.getKeyMetadata("k1"));
+
+ withManager.sendBodyAndHeader("direct:withManager", "x",
PQCConstants.KEY_ID, "k1");
+
+ assertNull(keyManager.getKeyMetadata("k1"), "the key state must
actually be gone");
+ }
+
+ @Test
+ void deletingWithoutAKeyIdIsRejected() {
+ CamelExecutionException e = assertThrows(CamelExecutionException.class,
+ () -> withManager.sendBody("direct:withManager", "x"));
+
+ assertTrue(e.getCause().getMessage().contains(PQCConstants.KEY_ID),
e.getCause().getMessage());
+ }
+}
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyLifecycleParityTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyLifecycleParityTest.java
new file mode 100644
index 000000000000..31854785db3b
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyLifecycleParityTest.java
@@ -0,0 +1,157 @@
+/*
+ * 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.lifecycle;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.bouncycastle.jcajce.spec.MLDSAParameterSpec;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Behaviour the five KeyLifecycleManager implementations are expected to
share, but did not.
+ */
+class KeyLifecycleParityTest {
+
+ @TempDir
+ Path keyDirectory;
+
+ @BeforeEach
+ void addProviders() {
+ 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 mldsaKeyPair() throws Exception {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-DSA", "BC");
+ kpg.initialize(MLDSAParameterSpec.ml_dsa_65, new SecureRandom());
+ return kpg.generateKeyPair();
+ }
+
+ @Test
+ void fileBasedListKeysSeesAKeyWrittenAfterStartup() throws Exception {
+ FileBasedKeyLifecycleManager writer = new
FileBasedKeyLifecycleManager(keyDirectory.toString());
+ FileBasedKeyLifecycleManager reader = new
FileBasedKeyLifecycleManager(keyDirectory.toString());
+
+ assertTrue(reader.listKeys().isEmpty(), "nothing stored yet");
+
+ // a second process, a restored backup or a sidecar writing into the
same directory
+ writer.generateKeyPair("MLDSA", "late-key");
+
+ List<KeyMetadata> keys = reader.listKeys();
+ assertEquals(1, keys.size(), "listKeys must scan the directory, not an
in-process cache");
+ assertEquals("late-key", keys.get(0).getKeyId());
+ }
+
+ @Test
+ void fileBasedListKeysIgnoresNonMetadataFiles() throws Exception {
+ FileBasedKeyLifecycleManager manager = new
FileBasedKeyLifecycleManager(keyDirectory.toString());
+ manager.generateKeyPair("MLDSA", "a-key");
+ Files.writeString(keyDirectory.resolve("notes.txt"), "not a key");
+
+ List<KeyMetadata> keys = manager.listKeys();
+ assertEquals(1, keys.size(), "only .metadata files describe a key");
+ assertEquals("a-key", keys.get(0).getKeyId());
+ }
+
+ @Test
+ void anUnreadableMetadataFileDoesNotSinkTheWholeListing() throws Exception
{
+ FileBasedKeyLifecycleManager manager = new
FileBasedKeyLifecycleManager(keyDirectory.toString());
+ manager.generateKeyPair("MLDSA", "good-key");
+
+ // a corrupt or half-written metadata file, which TRUNCATE_EXISTING
makes possible for a concurrent writer
+ Files.writeString(keyDirectory.resolve("broken.metadata"), "not json
at all");
+ Files.writeString(keyDirectory.resolve("empty.metadata"), "");
+
+ List<KeyMetadata> keys = new
FileBasedKeyLifecycleManager(keyDirectory.toString()).listKeys();
+
+ assertEquals(1, keys.size(), "an unreadable file must be skipped, not
fail the whole listing");
+ assertEquals("good-key", keys.get(0).getKeyId());
+ }
+
+ @Test
+ void aCombinedPemExportCanBeImportedBack() throws Exception {
+ KeyPair original = mldsaKeyPair();
+
+ byte[] pem = KeyFormatConverter.exportKeyPair(original,
KeyLifecycleManager.KeyFormat.PEM, true);
+ String text = new String(pem, StandardCharsets.UTF_8);
+ assertTrue(text.contains("-----BEGIN PUBLIC KEY-----"), "the export
must carry the public block");
+ assertTrue(text.contains("-----BEGIN PRIVATE KEY-----"), "the export
must carry the private block");
+
+ // before the fix, stripping only the PRIVATE armour left the PUBLIC
armour in the Base64 input
+ assertNotNull(KeyFormatConverter.importPrivateKey(pem,
KeyLifecycleManager.KeyFormat.PEM, "ML-DSA"));
+ assertNotNull(KeyFormatConverter.importPublicKey(pem,
KeyLifecycleManager.KeyFormat.PEM, "ML-DSA"));
+
+ assertEquals(original.getPrivate(),
+ KeyFormatConverter.importPrivateKey(pem,
KeyLifecycleManager.KeyFormat.PEM, "ML-DSA"));
+ assertEquals(original.getPublic(),
+ KeyFormatConverter.importPublicKey(pem,
KeyLifecycleManager.KeyFormat.PEM, "ML-DSA"));
+ }
+
+ @Test
+ void aPublicOnlyPemStillRoundTrips() throws Exception {
+ KeyPair original = mldsaKeyPair();
+
+ byte[] pem = KeyFormatConverter.exportKeyPair(original,
KeyLifecycleManager.KeyFormat.PEM, false);
+
+ assertEquals(original.getPublic(),
+ KeyFormatConverter.importPublicKey(pem,
KeyLifecycleManager.KeyFormat.PEM, "ML-DSA"));
+ }
+
+ @Test
+ void updatingMetadataDoesNotRewriteTheKeyFiles() throws Exception {
+ FileBasedKeyLifecycleManager manager = new
FileBasedKeyLifecycleManager(keyDirectory.toString());
+ manager.generateKeyPair("MLDSA", "a-key");
+
+ Path privateKeyFile;
+ try (Stream<Path> files = Files.list(keyDirectory)) {
+ privateKeyFile = files.filter(p ->
p.getFileName().toString().contains("private"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no private key file
was written"));
+ }
+ long before = Files.getLastModifiedTime(privateKeyFile).toMillis();
+
+ KeyMetadata metadata = manager.getKeyMetadata("a-key");
+ metadata.setStatus(KeyMetadata.KeyStatus.PENDING_ROTATION);
+ manager.updateKeyMetadata("a-key", metadata);
+
+ assertEquals(before,
Files.getLastModifiedTime(privateKeyFile).toMillis(),
+ "a metadata update must not rewrite the private key");
+ assertEquals(KeyMetadata.KeyStatus.PENDING_ROTATION,
+ new
FileBasedKeyLifecycleManager(keyDirectory.toString()).getKeyMetadata("a-key").getStatus(),
+ "the new status must be readable by another instance");
+ }
+}
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 3fda251c39ed..6e83564fcea8 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
@@ -342,6 +342,33 @@ All commands find running integrations the same way:
* The `--timeout` of `camel cmd send`, `camel cmd receive`, `camel eval
expression`, `camel transform message` and the
`camel semantic` commands also takes a duration such as `30s` (a number is
still millis).
+=== camel-pqc - KeyLifecycleManager behaviour aligned across the
implementations
+
+Four differences between the `KeyLifecycleManager` implementations are
resolved. All four are corrections, but each
+is observable.
+
+* `updateKeyMetadata` on the AWS Secrets Manager, Azure Key Vault and
HashiCorp Vault managers no longer re-stores
+ the key pair. It previously read the key back and wrote it again, creating a
new version of the private key secret
+ on every status change - with the default `max_versions = 10` of a Vault KV
v2 mount, ten metadata edits purged
+ every historical version of the private key. It also meant `revokeKey` threw
for a key whose private secret was
+ already gone. The file-based manager already wrote metadata only.
+* `FileBasedKeyLifecycleManager.listKeys()` now scans the key directory
instead of returning an in-process cache
+ filled once at construction. A key file written after start-up - by another
process, a restored backup or a
+ sidecar - was previously invisible, which silently stopped
`KeyRotationScheduler` from rotating it.
+* The `deleteKeyState` operation now fails with `IllegalStateException` when
no `keyLifecycleManager` is configured,
+ as every other key lifecycle operation already did. It previously reported
success having deleted nothing.
+* `exportKey` with `PEM` and `includePrivate = true` produces a document that
`importKey` can read back. The
+ combined export carries both a `PUBLIC KEY` and a `PRIVATE KEY` block, and
the parser stripped only one pair of
+ armour lines, leaving the other in the Base64 input and failing with
`Illegal base64 character 2d`. The export
+ format itself is unchanged, so documents written by earlier versions are
unaffected.
+* `importKey` resolves the `KeyFactory` with the BouncyCastle provider that
serves the algorithm, as the file-based
+ manager's key loading already did. On JDK 24 and later the JDK itself
implements ML-DSA and ML-KEM and is listed
+ before BouncyCastle, so the provider-less lookup used until now returned a
JDK key, and signing with it through the
+ component's BouncyCastle `Signature` failed with `unknown private key passed
to ML-DSA`. A key imported through the
+ AWS Secrets Manager, Azure Key Vault, HashiCorp Vault or in-memory manager
is now a BouncyCastle key on every JDK.
+
+PEM handling is also now explicitly UTF-8 rather than the platform default
charset.
+
=== camel-jbang - exit codes, and errors on stderr
The Camel CLI has a documented set of exit codes (see
xref:camel-jbang-exit-codes.adoc[Exit Codes and Output]):