This is an automated email from the ASF dual-hosted git repository.
oscerd 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 2c57c0c1d2b4 CAMEL-22538: Camel-PQC - Add Azure Key Vault Lifecycle
Manager (#24953)
2c57c0c1d2b4 is described below
commit 2c57c0c1d2b4154841394e5f866e4f2cb8475f49
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Jul 21 10:26:33 2026 +0200
CAMEL-22538: Camel-PQC - Add Azure Key Vault Lifecycle Manager (#24953)
Add AzureKeyVaultKeyLifecycleManager storing PQC keys in Azure Key Vault as
secrets (Key Vault key objects do not support post-quantum key material).
Private key (PKCS#8), public key (X.509) and metadata (JSON via
KeyMetadataCodec, no Java serialization) are stored as three distinct secrets
so different access policies can apply.
Co-authored-by: Claude Fable 5 <[email protected]>
---
components/camel-pqc/pom.xml | 30 ++
.../camel-pqc/src/main/docs/pqc-component.adoc | 2 +-
.../camel-pqc/src/main/docs/pqc-key-lifecycle.adoc | 104 ++++-
.../AzureKeyVaultKeyLifecycleManager.java | 471 +++++++++++++++++++++
.../AzureKeyVaultKeyLifecycleManagerTest.java | 201 +++++++++
5 files changed, 806 insertions(+), 2 deletions(-)
diff --git a/components/camel-pqc/pom.xml b/components/camel-pqc/pom.xml
index 4003805dbd65..5e84e47e7dc8 100644
--- a/components/camel-pqc/pom.xml
+++ b/components/camel-pqc/pom.xml
@@ -37,6 +37,18 @@
<skipITs.ppc64le>true</skipITs.ppc64le>
</properties>
+ <dependencyManagement>
+ <dependencies>
+ <dependency>
+ <groupId>com.azure</groupId>
+ <artifactId>azure-sdk-bom</artifactId>
+ <version>${azure-sdk-bom-version}</version>
+ <type>pom</type>
+ <scope>import</scope>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
+
<dependencies>
<dependency>
<groupId>org.apache.camel</groupId>
@@ -81,12 +93,30 @@
<optional>true</optional>
</dependency>
+ <!-- Azure SDK for AzureKeyVaultKeyLifecycleManager (optional) -->
+ <dependency>
+ <groupId>com.azure</groupId>
+ <artifactId>azure-security-keyvault-secrets</artifactId>
+ <optional>true</optional>
+ </dependency>
+ <dependency>
+ <groupId>com.azure</groupId>
+ <artifactId>azure-identity</artifactId>
+ <optional>true</optional>
+ </dependency>
+
<!-- for testing -->
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-test-junit6</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <version>${mockito-version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-crypto</artifactId>
diff --git a/components/camel-pqc/src/main/docs/pqc-component.adoc
b/components/camel-pqc/src/main/docs/pqc-component.adoc
index d99ebd56f793..e28bc707a3d8 100644
--- a/components/camel-pqc/src/main/docs/pqc-component.adoc
+++ b/components/camel-pqc/src/main/docs/pqc-component.adoc
@@ -178,7 +178,7 @@ the registry - for example a `SimpleMeterRegistry`, or the
registry provided by
The PQC component documentation is split into focused sub-pages:
-* xref:others:pqc-key-lifecycle.adoc[Key Lifecycle Management] - Key
generation, storage, rotation, expiration, revocation, and format conversion
with FileBasedKeyLifecycleManager, InMemoryKeyLifecycleManager, HashiCorp
Vault, and AWS Secrets Manager implementations
+* xref:others:pqc-key-lifecycle.adoc[Key Lifecycle Management] - Key
generation, storage, rotation, expiration, revocation, and format conversion
with FileBasedKeyLifecycleManager, InMemoryKeyLifecycleManager, HashiCorp
Vault, AWS Secrets Manager, and Azure Key Vault implementations
* xref:others:pqc-hybrid.adoc[Hybrid Cryptography] - Combining classical and
post-quantum algorithms for defense-in-depth, including wire format v2
* xref:dataformats:pqc-dataformat.adoc[PQC DataFormat] - DataFormat for
quantum-resistant encryption/decryption using KEM
diff --git a/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
b/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
index adbd2af4c146..8e69fb5e24dc 100644
--- a/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
+++ b/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
@@ -665,59 +665,161 @@ public AwsSecretsManagerKeyLifecycleManager
createKeyManager() {
}
----
+=== AzureKeyVaultKeyLifecycleManager
+
+An enterprise-grade implementation that integrates with Azure Key Vault for
centralized secret management. PQC keys
+are stored as Key Vault *secrets*, since Key Vault key objects do not support
post-quantum key material.
+
+**Security Implementation:**
+
+This implementation uses the same industry-standard formats and separation of
concerns as the AWS implementation:
+
+* **Private keys**: Stored in PKCS#8 format (RFC 5208), Base64-encoded in a
JSON envelope
+* **Public keys**: Stored in X.509/SubjectPublicKeyInfo format (RFC 5280),
Base64-encoded in a JSON envelope
+* **Separate storage**: Private keys, public keys, and metadata are stored as
distinct Key Vault secrets
+* **Fine-grained access policies**: Enables different Key Vault access
policies or Azure RBAC role assignments for
+ private keys (restricted) vs public keys (read-only)
+
+**Naming:** Azure Key Vault secret names may only contain alphanumeric
characters and dashes, so secrets are named
+`\{keyPrefix}-\{keyId}-private`, `\{keyPrefix}-\{keyId}-public` and
`\{keyPrefix}-\{keyId}-metadata`, with a default key
+prefix of `pqc-keys`. Key ids (and a custom key prefix) must only contain
alphanumeric characters and dashes.
+
+**Deletion:** Deleting a key initiates the Key Vault delete operation for the
backing secrets; on vaults with
+soft-delete enabled the secrets remain recoverable (and their names reserved)
until purged according to the vault
+retention policy.
+
+**Dependencies:**
+
+To use AzureKeyVaultKeyLifecycleManager, add the following optional
dependencies:
+
+[source,xml]
+----
+<dependency>
+ <groupId>com.azure</groupId>
+ <artifactId>azure-security-keyvault-secrets</artifactId>
+</dependency>
+<dependency>
+ <groupId>com.azure</groupId>
+ <artifactId>azure-identity</artifactId>
+</dependency>
+----
+
+**Example with SecretClient:**
+
+._Java-only: AzureKeyVaultKeyLifecycleManager with SecretClient_
+[source,java]
+----
+// Using existing SecretClient (recommended when using camel-azure-key-vault)
+@BindToRegistry("secretClient")
+public SecretClient createSecretClient() {
+ return new SecretClientBuilder()
+ .vaultUrl("https://myvault.vault.azure.net")
+ .credential(new DefaultAzureCredentialBuilder().build())
+ .buildClient();
+}
+
+@BindToRegistry("keyLifecycleManager")
+public AzureKeyVaultKeyLifecycleManager createKeyManager() {
+ return new AzureKeyVaultKeyLifecycleManager(
+ secretClient, // Reuse existing client
+ "pqc-keys" // Key prefix
+ );
+}
+
+// Generate an ML-DSA key stored in Azure Key Vault
+KeyPair keyPair = keyManager.generateKeyPair("MLDSA", "app-signing-key");
+
+// Keys are stored as: pqc-keys-app-signing-key-private, -public, -metadata
+----
+
+**Example with Client Secret Credential:**
+
+._Java-only: AzureKeyVaultKeyLifecycleManager with client secret credential_
+[source,java]
+----
+AzureKeyVaultKeyLifecycleManager keyManager =
+ new AzureKeyVaultKeyLifecycleManager(
+ "https://myvault.vault.azure.net", // Key Vault URL
+ "client-id", // Azure AD application client id
+ "client-secret", // Azure AD application client
secret
+ "tenant-id", // Azure AD tenant id
+ "pqc-keys" // key prefix (optional, defaults
to "pqc-keys")
+ );
+----
+
+**Example with Default Azure Credential (managed identity):**
+
+._Java-only: AzureKeyVaultKeyLifecycleManager with Default Azure Credential_
+[source,java]
+----
+AzureKeyVaultKeyLifecycleManager keyManager =
+ new AzureKeyVaultKeyLifecycleManager(
+ "https://myvault.vault.azure.net", // Key Vault URL
+ "pqc-keys" // key prefix
+ );
+----
+
=== Comparison of Implementations
[options="header"]
|===
-|Feature |FileBasedKeyLifecycleManager |InMemoryKeyLifecycleManager
|HashicorpVaultKeyLifecycleManager |AwsSecretsManagerKeyLifecycleManager
+|Feature |FileBasedKeyLifecycleManager |InMemoryKeyLifecycleManager
|HashicorpVaultKeyLifecycleManager |AwsSecretsManagerKeyLifecycleManager
|AzureKeyVaultKeyLifecycleManager
|Persistence
|File system
|Memory only
|Vault backend
|AWS Secrets Manager
+|Azure Key Vault
|Distributed
|Single node
|Single node
|Multi-node
|Multi-region
+|Multi-region
|Audit Logging
|Manual
|None
|Automatic (Vault)
|Automatic (CloudTrail)
+|Automatic (Azure Monitor)
|Access Control
|File permissions
|None
|Vault policies
|IAM policies
+|Access policies / Azure RBAC
|Encryption at Rest
|OS-dependent
|N/A
|Always (Vault)
|Always (AWS KMS)
+|Always (Key Vault)
|High Availability
|No
|No
|Yes (Vault HA)
|Yes (AWS Multi-AZ)
+|Yes (Azure zone-redundant)
|External Dependencies
|None
|None
|Vault + spring-vault
|AWS SDK v2
+|Azure SDK
|Use Case
|Single server
|Testing/Dev
|Production/Enterprise (Vault)
|Production/Enterprise (AWS)
+|Production/Enterprise (Azure)
|===
== Key Generation
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
new file mode 100644
index 000000000000..239a43322d41
--- /dev/null
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AzureKeyVaultKeyLifecycleManager.java
@@ -0,0 +1,471 @@
+/*
+ * 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.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
+
+import com.azure.core.credential.TokenCredential;
+import com.azure.core.exception.ResourceNotFoundException;
+import com.azure.identity.ClientSecretCredentialBuilder;
+import com.azure.identity.DefaultAzureCredentialBuilder;
+import com.azure.security.keyvault.secrets.SecretClient;
+import com.azure.security.keyvault.secrets.SecretClientBuilder;
+import com.azure.security.keyvault.secrets.models.KeyVaultSecret;
+import com.azure.security.keyvault.secrets.models.SecretProperties;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Azure Key Vault-based implementation of KeyLifecycleManager. Stores PQC
keys in Azure Key Vault as secrets, since Key
+ * Vault key objects do not support post-quantum key material.
+ *
+ * <p>
+ * Keys are stored using industry-standard formats, mirroring the AWS Secrets
Manager implementation:
+ * <ul>
+ * <li><b>Private keys</b>: PKCS#8 format (RFC 5208), Base64-encoded in a JSON
envelope</li>
+ * <li><b>Public keys</b>: X.509/SubjectPublicKeyInfo format (RFC 5280),
Base64-encoded in a JSON envelope</li>
+ * <li><b>Metadata</b>: JSON (see {@link KeyMetadataCodec})</li>
+ * </ul>
+ *
+ * <p>
+ * Private key, public key, and metadata are stored as three distinct Key
Vault secrets, so different access policies
+ * can be applied to private material (restricted) and public material
(read-only).
+ *
+ * <p>
+ * Azure Key Vault secret names may only contain alphanumeric characters and
dashes, so secrets are named
+ * {@code {keyPrefix}-{keyId}-private}, {@code {keyPrefix}-{keyId}-public} and
{@code {keyPrefix}-{keyId}-metadata},
+ * with a default key prefix of {@code pqc-keys}. Key ids (and a custom key
prefix) must therefore only contain
+ * alphanumeric characters and dashes.
+ *
+ * <p>
+ * Deleting a key initiates the Key Vault delete operation for the backing
secrets; on vaults with soft-delete enabled
+ * the secrets remain recoverable (and their names reserved) until purged
according to the vault retention policy.
+ */
+public class AzureKeyVaultKeyLifecycleManager implements KeyLifecycleManager {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(AzureKeyVaultKeyLifecycleManager.class);
+
+ private static final Pattern NAME_PATTERN =
Pattern.compile("[0-9a-zA-Z-]+");
+
+ private final SecretClient secretClient;
+ private final String keyPrefix;
+ private final ConcurrentHashMap<String, KeyPair> keyCache = new
ConcurrentHashMap<>();
+ private final ConcurrentHashMap<String, KeyMetadata> metadataCache = new
ConcurrentHashMap<>();
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ /**
+ * Create an AzureKeyVaultKeyLifecycleManager with an existing SecretClient
+ *
+ * @param secretClient Configured SecretClient instance
+ * @param keyPrefix Prefix for secret names in Azure Key Vault
(alphanumeric characters and dashes only)
+ */
+ public AzureKeyVaultKeyLifecycleManager(SecretClient secretClient, String
keyPrefix) {
+ this.secretClient = secretClient;
+ this.keyPrefix = validateName(keyPrefix != null ? keyPrefix :
"pqc-keys", "keyPrefix");
+
+ LOG.info("Initialized AzureKeyVaultKeyLifecycleManager with keyPrefix:
{}", this.keyPrefix);
+
+ try {
+ loadExistingKeys();
+ } catch (Exception e) {
+ LOG.warn("Failed to load existing keys from Azure Key Vault", e);
+ }
+ }
+
+ /**
+ * Create an AzureKeyVaultKeyLifecycleManager authenticating with a client
secret credential
+ *
+ * @param vaultUrl Key Vault URL (e.g.,
https://myvault.vault.azure.net)
+ * @param clientId Azure AD application client id
+ * @param clientSecret Azure AD application client secret
+ * @param tenantId Azure AD tenant id
+ * @param keyPrefix Prefix for secret names (optional, defaults to
"pqc-keys")
+ */
+ public AzureKeyVaultKeyLifecycleManager(String vaultUrl, String clientId,
String clientSecret, String tenantId,
+ String keyPrefix) {
+ this(buildClient(vaultUrl, new ClientSecretCredentialBuilder()
+ .clientId(clientId)
+ .clientSecret(clientSecret)
+ .tenantId(tenantId)
+ .build()),
+ keyPrefix);
+ }
+
+ /**
+ * Create an AzureKeyVaultKeyLifecycleManager authenticating through the
Default Azure Credential chain (managed
+ * identity, environment variables, Azure CLI, etc.)
+ *
+ * @param vaultUrl Key Vault URL (e.g., https://myvault.vault.azure.net)
+ * @param keyPrefix Prefix for secret names (optional, defaults to
"pqc-keys")
+ */
+ public AzureKeyVaultKeyLifecycleManager(String vaultUrl, String keyPrefix)
{
+ this(buildClient(vaultUrl, new
DefaultAzureCredentialBuilder().build()), keyPrefix);
+ }
+
+ private static SecretClient buildClient(String vaultUrl, TokenCredential
credential) {
+ return new SecretClientBuilder()
+ .vaultUrl(vaultUrl)
+ .credential(credential)
+ .buildClient();
+ }
+
+ @Override
+ public KeyPair generateKeyPair(String algorithm, String keyId) throws
Exception {
+ return generateKeyPair(algorithm, keyId, null);
+ }
+
+ @Override
+ public KeyPair generateKeyPair(String algorithm, String keyId, Object
parameterSpec) throws Exception {
+ LOG.info("Generating key pair for algorithm: {}, keyId: {}",
algorithm, keyId);
+
+ KeyPair keyPair = KeyAlgorithmSupport.generateKeyPair(algorithm,
parameterSpec);
+
+ // Create metadata
+ KeyMetadata metadata = new KeyMetadata(keyId, algorithm);
+ metadata.setDescription("Generated on " + new Date());
+
+ // Store the key
+ storeKey(keyId, keyPair, metadata);
+
+ LOG.info("Generated key pair in Azure Key Vault: {}", metadata);
+ return keyPair;
+ }
+
+ @Override
+ public byte[] exportKey(KeyPair keyPair, KeyFormat format, boolean
includePrivate) throws Exception {
+ return KeyFormatConverter.exportKeyPair(keyPair, format,
includePrivate);
+ }
+
+ @Override
+ public byte[] exportPublicKey(KeyPair keyPair, KeyFormat format) throws
Exception {
+ return KeyFormatConverter.exportPublicKey(keyPair.getPublic(), format);
+ }
+
+ @Override
+ public KeyPair importKey(byte[] keyData, KeyFormat format, String
algorithm) throws Exception {
+ // Try to import as private key first (which includes public key)
+ try {
+ PrivateKey privateKey
+ = KeyFormatConverter.importPrivateKey(keyData, format,
KeyAlgorithmSupport.getAlgorithmName(algorithm));
+ LOG.warn("Importing private key only - public key derivation may
be needed");
+ return new KeyPair(null, privateKey);
+ } catch (Exception e) {
+ // Try as public key only
+ PublicKey publicKey
+ = KeyFormatConverter.importPublicKey(keyData, format,
KeyAlgorithmSupport.getAlgorithmName(algorithm));
+ return new KeyPair(publicKey, null);
+ }
+ }
+
+ @Override
+ public KeyPair rotateKey(String oldKeyId, String newKeyId, String
algorithm) throws Exception {
+ LOG.info("Rotating key from {} to {}", oldKeyId, newKeyId);
+
+ // Get old key metadata
+ KeyMetadata oldMetadata = getKeyMetadata(oldKeyId);
+ if (oldMetadata == null) {
+ throw new IllegalArgumentException("Old key not found: " +
oldKeyId);
+ }
+
+ // Mark old key as deprecated
+ oldMetadata.setStatus(KeyMetadata.KeyStatus.DEPRECATED);
+ updateKeyMetadata(oldKeyId, oldMetadata);
+
+ // Generate new key
+ KeyPair newKeyPair = generateKeyPair(algorithm, newKeyId);
+
+ LOG.info("Key rotation completed in Azure Key Vault: {} -> {}",
oldKeyId, newKeyId);
+ return newKeyPair;
+ }
+
+ @Override
+ public void storeKey(String keyId, KeyPair keyPair, KeyMetadata metadata)
throws Exception {
+ // Use PKCS#8 format for private key and X.509 for public key
(industry standard)
+ byte[] privateKeyBytes = keyPair.getPrivate().getEncoded(); // PKCS#8
format
+ byte[] publicKeyBytes = keyPair.getPublic().getEncoded(); //
X.509/SubjectPublicKeyInfo format
+ String privateKeyBase64 =
Base64.getEncoder().encodeToString(privateKeyBytes);
+ String publicKeyBase64 =
Base64.getEncoder().encodeToString(publicKeyBytes);
+
+ // Store private key separately (restricted access policy recommended
in production)
+ String privateSecretName = getSecretName(keyId, "private");
+ String privateSecretValue = objectMapper.writeValueAsString(new
SecretData(
+ privateKeyBase64,
+ "PKCS8",
+ metadata.getAlgorithm()));
+
+ secretClient.setSecret(privateSecretName, privateSecretValue);
+
+ // Store public key separately (can have read-only access policy)
+ String publicSecretName = getSecretName(keyId, "public");
+ String publicSecretValue = objectMapper.writeValueAsString(new
SecretData(
+ publicKeyBase64,
+ "X509",
+ metadata.getAlgorithm()));
+
+ secretClient.setSecret(publicSecretName, publicSecretValue);
+
+ // Store metadata separately as JSON (see KeyMetadataCodec)
+ String metadataSecretName = getSecretName(keyId, "metadata");
+ String metadataSecretValue = KeyMetadataCodec.toJson(metadata);
+
+ secretClient.setSecret(metadataSecretName, metadataSecretValue);
+
+ // Update caches
+ keyCache.put(keyId, keyPair);
+ metadataCache.put(keyId, metadata);
+
+ LOG.debug("Stored private key, public key, and metadata separately in
Azure Key Vault for: {}", keyId);
+ }
+
+ @Override
+ public KeyPair getKey(String keyId) throws Exception {
+ // Check cache first
+ if (keyCache.containsKey(keyId)) {
+ return keyCache.get(keyId);
+ }
+
+ // Read private key from Azure Key Vault
+ KeyVaultSecret privateSecret =
secretClient.getSecret(getSecretName(keyId, "private"));
+
+ // Read public key from Azure Key Vault
+ KeyVaultSecret publicSecret =
secretClient.getSecret(getSecretName(keyId, "public"));
+
+ // Parse secret values
+ SecretData privateData =
objectMapper.readValue(privateSecret.getValue(), SecretData.class);
+ SecretData publicData =
objectMapper.readValue(publicSecret.getValue(), SecretData.class);
+
+ byte[] privateKeyBytes =
Base64.getDecoder().decode(privateData.getKey());
+ byte[] publicKeyBytes =
Base64.getDecoder().decode(publicData.getKey());
+
+ // Use KeyFormatConverter to reconstruct keys from standard formats
+ PrivateKey privateKey =
KeyFormatConverter.importPrivateKey(privateKeyBytes,
+ KeyLifecycleManager.KeyFormat.DER,
KeyAlgorithmSupport.getAlgorithmName(privateData.getAlgorithm()));
+ PublicKey publicKey =
KeyFormatConverter.importPublicKey(publicKeyBytes,
+ KeyLifecycleManager.KeyFormat.DER,
KeyAlgorithmSupport.getAlgorithmName(publicData.getAlgorithm()));
+
+ KeyPair keyPair = new KeyPair(publicKey, privateKey);
+
+ // Cache it
+ keyCache.put(keyId, keyPair);
+ return keyPair;
+ }
+
+ @Override
+ public KeyMetadata getKeyMetadata(String keyId) throws Exception {
+ // Check cache first
+ if (metadataCache.containsKey(keyId)) {
+ return metadataCache.get(keyId);
+ }
+
+ try {
+ KeyVaultSecret secret =
secretClient.getSecret(getSecretName(keyId, "metadata"));
+ KeyMetadata metadata =
KeyMetadataCodec.fromJson(secret.getValue());
+
+ // Cache it
+ metadataCache.put(keyId, metadata);
+ return metadata;
+ } catch (ResourceNotFoundException e) {
+ return null;
+ }
+ }
+
+ @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);
+ }
+
+ @Override
+ public void deleteKey(String keyId) throws Exception {
+ // Delete private key, public key, and metadata separately
+ deleteSecret(getSecretName(keyId, "private"));
+ deleteSecret(getSecretName(keyId, "public"));
+ deleteSecret(getSecretName(keyId, "metadata"));
+
+ keyCache.remove(keyId);
+ metadataCache.remove(keyId);
+
+ LOG.info("Deleted private key, public key, and metadata from Azure Key
Vault: {}", keyId);
+ }
+
+ @Override
+ public List<KeyMetadata> listKeys() throws Exception {
+ // List all secrets with the key prefix, only processing metadata
secrets to avoid duplicates
+ List<KeyMetadata> metadataList = new ArrayList<>();
+
+ for (SecretProperties properties :
secretClient.listPropertiesOfSecrets()) {
+ String secretName = properties.getName();
+ if (secretName.startsWith(keyPrefix + "-") &&
secretName.endsWith("-metadata")) {
+ String keyId = extractKeyIdFromSecretName(secretName);
+ try {
+ KeyMetadata metadata = getKeyMetadata(keyId);
+ if (metadata != null) {
+ metadataList.add(metadata);
+ }
+ } catch (Exception e) {
+ LOG.warn("Failed to load metadata for key: {}", keyId, e);
+ }
+ }
+ }
+
+ return metadataList;
+ }
+
+ @Override
+ public boolean needsRotation(String keyId, Duration maxAge, long maxUsage)
throws Exception {
+ KeyMetadata metadata = getKeyMetadata(keyId);
+ if (metadata == null) {
+ return false;
+ }
+
+ if (metadata.needsRotation()) {
+ return true;
+ }
+
+ if (maxAge != null && metadata.getAgeInDays() > maxAge.toDays()) {
+ return true;
+ }
+
+ if (maxUsage > 0 && metadata.getUsageCount() >= maxUsage) {
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void expireKey(String keyId) throws Exception {
+ KeyMetadata metadata = getKeyMetadata(keyId);
+ if (metadata != null) {
+ metadata.setStatus(KeyMetadata.KeyStatus.EXPIRED);
+ updateKeyMetadata(keyId, metadata);
+ LOG.info("Expired key in Azure Key Vault: {}", keyId);
+ }
+ }
+
+ @Override
+ public void revokeKey(String keyId, String reason) throws Exception {
+ KeyMetadata metadata = getKeyMetadata(keyId);
+ if (metadata != null) {
+ metadata.setStatus(KeyMetadata.KeyStatus.REVOKED);
+ metadata.setDescription((metadata.getDescription() != null ?
metadata.getDescription() + "; " : "")
+ + "Revoked: " + reason);
+ updateKeyMetadata(keyId, metadata);
+ LOG.info("Revoked key in Azure Key Vault: {} - {}", keyId, reason);
+ }
+ }
+
+ private void loadExistingKeys() throws Exception {
+ List<KeyMetadata> keys = listKeys();
+ if (!keys.isEmpty()) {
+ LOG.info("Found {} existing keys in Azure Key Vault", keys.size());
+ for (KeyMetadata metadata : keys) {
+ LOG.debug("Loaded existing key from Azure Key Vault: {}",
metadata);
+ }
+ }
+ }
+
+ private String getSecretName(String keyId, String type) {
+ validateName(keyId, "keyId");
+ return keyPrefix + "-" + keyId + "-" + type;
+ }
+
+ private String extractKeyIdFromSecretName(String secretName) {
+ // Extract keyId from pattern: {keyPrefix}-{keyId}-metadata
+ return secretName.substring(keyPrefix.length() + 1,
secretName.length() - "-metadata".length());
+ }
+
+ private void deleteSecret(String secretName) {
+ try {
+ secretClient.beginDeleteSecret(secretName);
+ LOG.debug("Deletion initiated for secret: {}", secretName);
+ } catch (ResourceNotFoundException e) {
+ LOG.debug("Secret not found, skipping deletion: {}", secretName);
+ }
+ }
+
+ private static String validateName(String value, String what) {
+ if (value == null || !NAME_PATTERN.matcher(value).matches()) {
+ throw new IllegalArgumentException(
+ what + " must contain only alphanumeric characters and
dashes to be usable in Azure Key Vault secret names: "
+ + value);
+ }
+ return value;
+ }
+
+ /**
+ * Get the underlying SecretClient for advanced operations
+ */
+ public SecretClient getSecretClient() {
+ return secretClient;
+ }
+
+ /**
+ * Helper class for storing secret data in JSON format
+ */
+ private static class SecretData {
+ private String key;
+ private String format;
+ private String algorithm;
+
+ public SecretData() {
+ }
+
+ public SecretData(String key, String format, String algorithm) {
+ this.key = key;
+ this.format = format;
+ this.algorithm = algorithm;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public void setKey(String key) {
+ this.key = key;
+ }
+
+ public String getFormat() {
+ return format;
+ }
+
+ public void setFormat(String format) {
+ this.format = format;
+ }
+
+ public String getAlgorithm() {
+ return algorithm;
+ }
+
+ public void setAlgorithm(String algorithm) {
+ this.algorithm = algorithm;
+ }
+ }
+}
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/AzureKeyVaultKeyLifecycleManagerTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/AzureKeyVaultKeyLifecycleManagerTest.java
new file mode 100644
index 000000000000..5e974b2184b3
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/AzureKeyVaultKeyLifecycleManagerTest.java
@@ -0,0 +1,201 @@
+/*
+ * 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.security.KeyPair;
+import java.security.Security;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.azure.core.exception.ResourceNotFoundException;
+import com.azure.core.http.rest.PagedIterable;
+import com.azure.security.keyvault.secrets.SecretClient;
+import com.azure.security.keyvault.secrets.models.KeyVaultSecret;
+import com.azure.security.keyvault.secrets.models.SecretProperties;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit test for AzureKeyVaultKeyLifecycleManager backed by a mocked
SecretClient simulating the Key Vault secret store.
+ */
+class AzureKeyVaultKeyLifecycleManagerTest {
+
+ private Map<String, String> store;
+ private SecretClient client;
+ private AzureKeyVaultKeyLifecycleManager manager;
+
+ @BeforeAll
+ static void setupProviders() {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ if (Security.getProvider(BouncyCastlePQCProvider.PROVIDER_NAME) ==
null) {
+ Security.addProvider(new BouncyCastlePQCProvider());
+ }
+ }
+
+ @BeforeEach
+ void setup() {
+ store = new ConcurrentHashMap<>();
+ client = mockSecretClient(store);
+ manager = new AzureKeyVaultKeyLifecycleManager(client, "pqc-keys");
+ }
+
+ @SuppressWarnings("unchecked")
+ private static SecretClient mockSecretClient(Map<String, String> store) {
+ SecretClient client = mock(SecretClient.class);
+ when(client.setSecret(anyString(), anyString())).thenAnswer(inv -> {
+ String name = inv.getArgument(0);
+ String value = inv.getArgument(1);
+ store.put(name, value);
+ return new KeyVaultSecret(name, value);
+ });
+ when(client.getSecret(anyString())).thenAnswer(inv -> {
+ String name = inv.getArgument(0);
+ String value = store.get(name);
+ if (value == null) {
+ throw new ResourceNotFoundException("Secret not found: " +
name, null);
+ }
+ return new KeyVaultSecret(name, value);
+ });
+ when(client.listPropertiesOfSecrets()).thenAnswer(inv -> {
+ List<SecretProperties> properties = new ArrayList<>();
+ for (String name : store.keySet()) {
+ SecretProperties p = mock(SecretProperties.class);
+ when(p.getName()).thenReturn(name);
+ properties.add(p);
+ }
+ PagedIterable<SecretProperties> iterable =
mock(PagedIterable.class);
+ when(iterable.iterator()).thenReturn(properties.iterator());
+ return iterable;
+ });
+ when(client.beginDeleteSecret(anyString())).thenAnswer(inv -> {
+ String name = inv.getArgument(0);
+ if (store.remove(name) == null) {
+ throw new ResourceNotFoundException("Secret not found: " +
name, null);
+ }
+ return null;
+ });
+ return client;
+ }
+
+ @Test
+ void testGenerateStoresKeyPairAndMetadataAsSeparateSecrets() throws
Exception {
+ KeyPair keyPair = manager.generateKeyPair("MLDSA", "signing-key");
+
+ assertNotNull(keyPair);
+ assertTrue(store.containsKey("pqc-keys-signing-key-private"));
+ assertTrue(store.containsKey("pqc-keys-signing-key-public"));
+ assertTrue(store.containsKey("pqc-keys-signing-key-metadata"));
+ assertEquals(3, store.size());
+ }
+
+ @Test
+ void testKeyPairAndMetadataRoundTripThroughTheVault() throws Exception {
+ KeyPair generated = manager.generateKeyPair("MLDSA", "roundtrip-key");
+
+ // A fresh manager over the same vault has empty caches, so it must
read back from the (mocked) store
+ AzureKeyVaultKeyLifecycleManager fresh = new
AzureKeyVaultKeyLifecycleManager(client, "pqc-keys");
+ KeyPair loaded = fresh.getKey("roundtrip-key");
+
+ assertArrayEquals(generated.getPrivate().getEncoded(),
loaded.getPrivate().getEncoded());
+ assertArrayEquals(generated.getPublic().getEncoded(),
loaded.getPublic().getEncoded());
+
+ KeyMetadata metadata = fresh.getKeyMetadata("roundtrip-key");
+ assertNotNull(metadata);
+ assertEquals("roundtrip-key", metadata.getKeyId());
+ assertEquals("MLDSA", metadata.getAlgorithm());
+ assertEquals(KeyMetadata.KeyStatus.ACTIVE, metadata.getStatus());
+ }
+
+ @Test
+ void testListKeysReturnsOneEntryPerKey() throws Exception {
+ manager.generateKeyPair("MLDSA", "key-one");
+ manager.generateKeyPair("MLDSA", "key-two");
+
+ List<KeyMetadata> keys = manager.listKeys();
+
+ assertEquals(2, keys.size());
+ }
+
+ @Test
+ void testDeleteKeyRemovesAllSecrets() throws Exception {
+ manager.generateKeyPair("MLDSA", "doomed-key");
+ assertEquals(3, store.size());
+
+ manager.deleteKey("doomed-key");
+
+ assertTrue(store.isEmpty());
+ assertNull(manager.getKeyMetadata("doomed-key"));
+ }
+
+ @Test
+ void testRotateKeyDeprecatesOldKey() throws Exception {
+ manager.generateKeyPair("MLDSA", "old-key");
+
+ KeyPair rotated = manager.rotateKey("old-key", "new-key", "MLDSA");
+
+ assertNotNull(rotated);
+ assertEquals(KeyMetadata.KeyStatus.DEPRECATED,
manager.getKeyMetadata("old-key").getStatus());
+ assertEquals(KeyMetadata.KeyStatus.ACTIVE,
manager.getKeyMetadata("new-key").getStatus());
+ }
+
+ @Test
+ void testExpireAndRevoke() throws Exception {
+ manager.generateKeyPair("MLDSA", "state-key");
+
+ manager.expireKey("state-key");
+ assertEquals(KeyMetadata.KeyStatus.EXPIRED,
manager.getKeyMetadata("state-key").getStatus());
+
+ manager.revokeKey("state-key", "compromised");
+ KeyMetadata metadata = manager.getKeyMetadata("state-key");
+ assertEquals(KeyMetadata.KeyStatus.REVOKED, metadata.getStatus());
+ assertTrue(metadata.getDescription().contains("Revoked: compromised"));
+ }
+
+ @Test
+ void testNeedsRotationForUnknownKeyIsFalse() throws Exception {
+ assertFalse(manager.needsRotation("no-such-key", null, 0));
+ }
+
+ @Test
+ void testGetKeyMetadataReturnsNullWhenMissing() throws Exception {
+ assertNull(manager.getKeyMetadata("missing-key"));
+ }
+
+ @Test
+ void testKeyIdWithInvalidCharactersIsRejected() {
+ assertThrows(IllegalArgumentException.class, () ->
manager.generateKeyPair("MLDSA", "bad/key"));
+ assertThrows(IllegalArgumentException.class, () -> new
AzureKeyVaultKeyLifecycleManager(client, "bad/prefix"));
+ }
+}