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]):

Reply via email to