+ * Implementations provide the cryptographic backend (HSM via PKCS#11, database, cloud KMS, etc.) + * for secure key wrapping/unwrapping using envelope encryption. + *
+ * Design principles: + * - KEKs (Key Encryption Keys) never leave the secure backend + * - DEKs (Data Encryption Keys) are wrapped by KEKs for storage + * - Plaintext DEKs exist only transiently in memory during wrap/unwrap + * - All operations are purpose-scoped to prevent key reuse + *
+ * Thread-safety: Implementations must be thread-safe for concurrent operations. + */ +public interface KMSProvider extends Configurable, Adapter { + + /** + * Returns {@code true} if the given HSM profile configuration key name refers + * to a + * sensitive value (PIN, password, secret, or private key) that must be + * encrypted at + * rest and masked in API responses. + * + *
+ * This is a shared naming-convention helper used by both KMS providers (when
+ * loading/storing profile details) and the KMS manager (when building API
+ * responses).
+ *
+ * @param key configuration key name (case-insensitive); null returns false
+ * @return true if the key is considered sensitive
+ */
+ static boolean isSensitiveKey(String key) {
+ if (key == null) {
+ return false;
+ }
+ return key.equalsIgnoreCase("pin") ||
+ key.equalsIgnoreCase("password") ||
+ key.toLowerCase().contains("secret") ||
+ key.equalsIgnoreCase("private_key");
+ }
+
+ /**
+ * Get the unique name of this provider
+ *
+ * @return provider name (e.g., "database", "pkcs11")
+ */
+ String getProviderName();
+
+ /**
+ * Create a new Key Encryption Key (KEK) in the secure backend.
+ * Delegates to {@link #createKek(KeyPurpose, String, int, Long)} with null profile ID.
+ *
+ * @param purpose the purpose/scope for this KEK
+ * @param label human-readable label for the KEK (must be unique within purpose)
+ * @param keyBits key size in bits (typically 128, 192, or 256)
+ * @return the KEK identifier (label or handle) for later reference
+ * @throws KMSException if KEK creation fails
+ */
+ default String createKek(KeyPurpose purpose, String label, int keyBits) throws KMSException {
+ return createKek(purpose, label, keyBits, null);
+ }
+
+ /**
+ * Create a new Key Encryption Key (KEK) in the secure backend with explicit HSM profile.
+ *
+ * @param purpose the purpose/scope for this KEK
+ * @param label human-readable label for the KEK (must be unique within purpose)
+ * @param keyBits key size in bits (typically 128, 192, or 256)
+ * @param hsmProfileId optional HSM profile ID to create the KEK in (null for auto-resolution/default)
+ * @return the KEK identifier (label or handle) for later reference
+ * @throws KMSException if KEK creation fails
+ */
+ String createKek(KeyPurpose purpose, String label, int keyBits, Long hsmProfileId) throws KMSException;
+
+ /**
+ * Delete a KEK from the secure backend.
+ * WARNING: This will make all DEKs wrapped by this KEK unrecoverable.
+ *
+ * @param kekId the KEK identifier to delete
+ * @throws KMSException if deletion fails or KEK not found
+ */
+ void deleteKek(String kekId) throws KMSException;
+
+ /**
+ * Validates the configuration details for this provider before saving an HSM
+ * profile.
+ * Implementations should override this to perform provider-specific validation.
+ *
+ * @param details the configuration details to validate
+ * @throws KMSException if validation fails
+ */
+ default void validateProfileConfig(java.util.Map
+ * SECURITY: Caller MUST zeroize the returned byte array after use
+ *
+ * @param wrappedKey the wrapped key to decrypt
+ * @return plaintext DEK (caller must zeroize!)
+ * @throws KMSException if unwrapping fails or KEK not found
+ */
+ default byte[] unwrapKey(WrappedKey wrappedKey) throws KMSException {
+ return unwrapKey(wrappedKey, null);
+ }
+
+ /**
+ * Unwrap (decrypt) a wrapped DEK to obtain the plaintext key using explicit HSM profile.
+ *
+ * SECURITY: Caller MUST zeroize the returned byte array after use
+ *
+ * @param wrappedKey the wrapped key to decrypt
+ * @param hsmProfileId optional HSM profile ID to use (null for auto-resolution/default)
+ * @return plaintext DEK (caller must zeroize!)
+ * @throws KMSException if unwrapping fails or KEK not found
+ */
+ byte[] unwrapKey(WrappedKey wrappedKey, Long hsmProfileId) throws KMSException;
+
+ /**
+ * Generate a new random DEK and immediately wrap it with a KEK.
+ * Delegates to {@link #generateAndWrapDek(KeyPurpose, String, int, Long)} with null profile ID.
+ * (convenience method combining generation + wrapping)
+ *
+ * @param purpose the intended purpose of the new DEK
+ * @param kekLabel the label of the KEK to use for wrapping
+ * @param keyBits DEK size in bits (typically 128, 192, or 256)
+ * @return WrappedKey containing the newly generated and wrapped DEK
+ * @throws KMSException if generation or wrapping fails
+ */
+ default WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits) throws KMSException {
+ return generateAndWrapDek(purpose, kekLabel, keyBits, null);
+ }
+
+ /**
+ * Generate a new random DEK and immediately wrap it with a KEK using explicit HSM profile.
+ * (convenience method combining generation + wrapping)
+ *
+ * @param purpose the intended purpose of the new DEK
+ * @param kekLabel the label of the KEK to use for wrapping
+ * @param keyBits DEK size in bits (typically 128, 192, or 256)
+ * @param hsmProfileId optional HSM profile ID to use (null for auto-resolution/default)
+ * @return WrappedKey containing the newly generated and wrapped DEK
+ * @throws KMSException if generation or wrapping fails
+ */
+ WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits,
+ Long hsmProfileId) throws KMSException;
+
+ /**
+ * Rewrap a DEK with a different KEK (used during key rotation).
+ * Delegates to {@link #rewrapKey(WrappedKey, String, Long)} with null profile ID.
+ * This unwraps with the old KEK and wraps with the new KEK without exposing the plaintext DEK.
+ *
+ * @param oldWrappedKey the currently wrapped key
+ * @param newKekLabel the label of the new KEK to wrap with
+ * @return new WrappedKey encrypted with the new KEK
+ * @throws KMSException if rewrapping fails
+ */
+ default WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel) throws KMSException {
+ return rewrapKey(oldWrappedKey, newKekLabel, null);
+ }
+
+ /**
+ * Rewrap a DEK with a different KEK (used during key rotation) using explicit target HSM profile.
+ * This unwraps with the old KEK and wraps with the new KEK without exposing the plaintext DEK.
+ *
+ * @param oldWrappedKey the currently wrapped key
+ * @param newKekLabel the label of the new KEK to wrap with
+ * @param targetHsmProfileId optional target HSM profile ID to wrap with (null for auto-resolution/default)
+ * @return new WrappedKey encrypted with the new KEK
+ * @throws KMSException if rewrapping fails
+ */
+ WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel, Long targetHsmProfileId) throws KMSException;
+
+ /**
+ * Perform health check on the provider backend
+ *
+ * @return true if provider is healthy and operational
+ * @throws KMSException if health check fails with critical error
+ */
+ boolean healthCheck() throws KMSException;
+
+ /**
+ * Invalidates any cached state (config, sessions) associated with the given HSM profile.
+ * Must be called after an HSM profile is updated or deleted so that the next operation
+ * re-reads the profile details from the database instead of using stale cached values.
+ *
+ * Providers that do not cache per-profile state (e.g. the database provider) can
+ * leave this as a no-op.
+ *
+ * @param profileId the HSM profile ID whose cache should be evicted
+ */
+ default void invalidateProfileCache(Long profileId) {
+ // no-op for providers that don't cache per-profile state
+ }
+}
diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java
new file mode 100644
index 000000000000..cea182eb75e5
--- /dev/null
+++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java
@@ -0,0 +1,76 @@
+// 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.cloudstack.framework.kms;
+
+/**
+ * Defines the purpose/usage scope for cryptographic keys in the KMS system.
+ * This enables proper key segregation and prevents key reuse across different contexts.
+ */
+public enum KeyPurpose {
+ /**
+ * Keys used for encrypting VM disk volumes (LUKS, encrypted storage)
+ */
+ VOLUME_ENCRYPTION("volume", "Volume disk encryption keys"),
+
+ /**
+ * Keys used for protecting TLS certificate private keys
+ */
+ TLS_CERT("tls", "TLS certificate private keys");
+
+ private final String name;
+ private final String description;
+
+ KeyPurpose(String name, String description) {
+ this.name = name;
+ this.description = description;
+ }
+
+ /**
+ * Convert string name to KeyPurpose enum
+ *
+ * @param name the string representation of the purpose
+ * @return matching KeyPurpose
+ * @throws IllegalArgumentException if no matching purpose found
+ */
+ public static KeyPurpose fromString(String name) {
+ for (KeyPurpose purpose : KeyPurpose.values()) {
+ if (purpose.getName().equalsIgnoreCase(name)) {
+ return purpose;
+ }
+ }
+ throw new IllegalArgumentException("Unknown KeyPurpose: " + name);
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * Generate a KEK label with purpose prefix
+ *
+ * @param customLabel optional custom label suffix
+ * @return formatted KEK label (e.g., "volume-kek-v1")
+ */
+ public String generateKekLabel(String customLabel) {
+ return name + "-kek-" + (customLabel != null ? customLabel : "v1");
+ }
+}
diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java
new file mode 100644
index 000000000000..e70c5e32c46a
--- /dev/null
+++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java
@@ -0,0 +1,131 @@
+// 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.cloudstack.framework.kms;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.Objects;
+
+/**
+ * Immutable Data Transfer Object representing an encrypted (wrapped) Data Encryption Key.
+ * The wrapped key material contains the DEK encrypted by a Key Encryption Key (KEK)
+ * stored in a secure backend (HSM, database, etc.).
+ *
+ * This follows the envelope encryption pattern:
+ * - DEK: encrypts actual data (e.g., disk volume)
+ * - KEK: encrypts the DEK (never leaves secure storage)
+ * - Wrapped Key: DEK encrypted by KEK, safe to store in database
+ */
+public class WrappedKey {
+ private final String uuid;
+ private final String kekId;
+ private final KeyPurpose purpose;
+ private final String algorithm;
+ private final byte[] wrappedKeyMaterial;
+ private final String providerName;
+ private final Date created;
+ private final Long zoneId;
+
+ /**
+ * Create a new WrappedKey instance
+ *
+ * @param kekId ID/label of the KEK used to wrap this key
+ * @param purpose the intended use of this key
+ * @param algorithm encryption algorithm (e.g., "AES/GCM/NoPadding")
+ * @param wrappedKeyMaterial the encrypted DEK blob
+ * @param providerName name of the KMS provider that created this key
+ * @param created timestamp when key was wrapped
+ * @param zoneId optional zone ID for zone-scoped keys
+ */
+ public WrappedKey(String kekId, KeyPurpose purpose, String algorithm,
+ byte[] wrappedKeyMaterial, String providerName,
+ Date created, Long zoneId) {
+ this(null, kekId, purpose, algorithm, wrappedKeyMaterial, providerName, created, zoneId);
+ }
+
+ /**
+ * Constructor for database-loaded keys with ID
+ */
+ public WrappedKey(String uuid, String kekId, KeyPurpose purpose, String algorithm,
+ byte[] wrappedKeyMaterial, String providerName,
+ Date created, Long zoneId) {
+ this.uuid = uuid;
+ this.kekId = Objects.requireNonNull(kekId, "kekId cannot be null");
+ this.purpose = Objects.requireNonNull(purpose, "purpose cannot be null");
+ this.algorithm = Objects.requireNonNull(algorithm, "algorithm cannot be null");
+ this.providerName = providerName;
+
+ if (wrappedKeyMaterial == null || wrappedKeyMaterial.length == 0) {
+ throw new IllegalArgumentException("wrappedKeyMaterial cannot be null or empty");
+ }
+ this.wrappedKeyMaterial = Arrays.copyOf(wrappedKeyMaterial, wrappedKeyMaterial.length);
+
+ this.created = created != null ? new Date(created.getTime()) : new Date();
+ this.zoneId = zoneId;
+ }
+
+ public String getUuid() {
+ return uuid;
+ }
+
+ public String getKekId() {
+ return kekId;
+ }
+
+ public KeyPurpose getPurpose() {
+ return purpose;
+ }
+
+ public String getAlgorithm() {
+ return algorithm;
+ }
+
+ /**
+ * Get wrapped key material. Returns a defensive copy to prevent modification.
+ * Caller is responsible for zeroizing the returned array after use.
+ */
+ public byte[] getWrappedKeyMaterial() {
+ return Arrays.copyOf(wrappedKeyMaterial, wrappedKeyMaterial.length);
+ }
+
+ public String getProviderName() {
+ return providerName;
+ }
+
+ public Date getCreated() {
+ return created != null ? new Date(created.getTime()) : null;
+ }
+
+ public Long getZoneId() {
+ return zoneId;
+ }
+
+ @Override
+ public String toString() {
+ return "WrappedKey{" +
+ "uuid='" + uuid + '\'' +
+ ", kekId='" + kekId + '\'' +
+ ", purpose=" + purpose +
+ ", algorithm='" + algorithm + '\'' +
+ ", providerName='" + providerName + '\'' +
+ ", materialLength=" + (wrappedKeyMaterial != null ? wrappedKeyMaterial.length : 0) +
+ ", created=" + created +
+ ", zoneId=" + zoneId +
+ '}';
+ }
+}
diff --git a/framework/pom.xml b/framework/pom.xml
index 337e5b0268b2..95d0bd0694c6 100644
--- a/framework/pom.xml
+++ b/framework/pom.xml
@@ -54,6 +54,7 @@
+ * This provider is suitable for deployments that don't have access to HSM hardware.
+ * The master KEKs are stored encrypted in the kms_database_kek_objects table using
+ * CloudStack's existing DBEncryptionUtil, with PKCS#11-compatible attributes.
+ */
+public class DatabaseKMSProvider extends AdapterBase implements KMSProvider {
+ private static final Logger logger = LogManager.getLogger(DatabaseKMSProvider.class);
+ private static final String PROVIDER_NAME = "database";
+ private static final int GCM_IV_LENGTH = 12; // 96 bits recommended for GCM
+ private static final int GCM_TAG_LENGTH = 16; // 128 bits
+ private static final String ALGORITHM = "AES/GCM/NoPadding";
+ private static final String CKO_SECRET_KEY = "CKO_SECRET_KEY";
+ private static final String CKK_AES = "CKK_AES";
+
+ private final SecureRandom secureRandom = new SecureRandom();
+ @Inject
+ private KMSDatabaseKekObjectDao kekObjectDao;
+
+ @Override
+ public String getProviderName() {
+ return PROVIDER_NAME;
+ }
+
+ @Override
+ public String createKek(KeyPurpose purpose, String label, int keyBits, Long hsmProfileId) throws KMSException {
+ // Database provider ignores hsmProfileId
+ return createKek(purpose, label, keyBits);
+ }
+
+ @Override
+ public String createKek(KeyPurpose purpose, String label, int keyBits) throws KMSException {
+ if (keyBits != 128 && keyBits != 192 && keyBits != 256) {
+ throw KMSException.invalidParameter("Key size must be 128, 192, or 256 bits");
+ }
+
+ if (StringUtils.isEmpty(label)) {
+ label = generateKekLabel(purpose);
+ }
+
+ if (kekObjectDao.existsByLabel(label)) {
+ throw KMSException.keyAlreadyExists("KEK with label " + label + " already exists");
+ }
+
+ byte[] kekBytes = new byte[keyBits / 8];
+ try {
+ secureRandom.nextBytes(kekBytes);
+
+ // Base64 encode then encrypt the KEK material using DBEncryptionUtil
+ String kekBase64 = Base64.getEncoder().encodeToString(kekBytes);
+ String encryptedKek = DBEncryptionUtil.encrypt(kekBase64);
+ byte[] encryptedKekBytes = encryptedKek.getBytes(StandardCharsets.UTF_8);
+
+ KMSDatabaseKekObjectVO kekObject = new KMSDatabaseKekObjectVO(label, purpose, keyBits, encryptedKekBytes);
+ kekObject.setObjectClass(CKO_SECRET_KEY);
+ kekObject.setKeyType(CKK_AES);
+ kekObject.setObjectId(label.getBytes(StandardCharsets.UTF_8));
+ kekObject.setAlgorithm(ALGORITHM);
+ kekObject.setIsSensitive(true);
+ kekObject.setIsExtractable(false);
+ kekObject.setIsToken(true);
+ kekObject.setIsPrivate(true);
+ kekObject.setIsModifiable(false);
+ kekObject.setIsCopyable(false);
+ kekObject.setIsDestroyable(true);
+ kekObject.setAlwaysSensitive(true);
+ kekObject.setNeverExtractable(true);
+
+ kekObjectDao.persist(kekObject);
+
+ logger.info("Created KEK with label {} for purpose {} (PKCS#11 object ID: {})", label, purpose,
+ kekObject.getId());
+ return label;
+
+ } catch (Exception e) {
+ throw KMSException.kekOperationFailed("Failed to create KEK: " + e.getMessage(), e);
+ } finally {
+ Arrays.fill(kekBytes, (byte) 0);
+ }
+ }
+
+ @Override
+ public void deleteKek(String kekId) throws KMSException {
+ KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekId);
+ if (kekObject == null) {
+ throw KMSException.kekNotFound("KEK with label " + kekId + " not found");
+ }
+
+ try {
+ kekObjectDao.remove(kekObject.getId());
+
+ if (kekObject.getKeyMaterial() != null) {
+ Arrays.fill(kekObject.getKeyMaterial(), (byte) 0);
+ }
+
+ logger.warn("Deleted KEK with label {}. All DEKs wrapped with this KEK are now unrecoverable!", kekId);
+ } catch (Exception e) {
+ throw KMSException.kekOperationFailed("Failed to delete KEK: " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public boolean isKekAvailable(String kekId) throws KMSException {
+ try {
+ KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekId);
+ return kekObject != null && kekObject.getRemoved() == null && kekObject.getKeyMaterial() != null;
+ } catch (Exception e) {
+ logger.warn("Error checking KEK availability: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ @Override
+ public WrappedKey wrapKey(byte[] plainKey, KeyPurpose purpose, String kekLabel,
+ Long hsmProfileId) throws KMSException {
+ // Database provider ignores hsmProfileId
+ return wrapKey(plainKey, purpose, kekLabel);
+ }
+
+ @Override
+ public WrappedKey wrapKey(byte[] plainKey, KeyPurpose purpose, String kekLabel) throws KMSException {
+ if (plainKey == null || plainKey.length == 0) {
+ throw KMSException.invalidParameter("Plain key cannot be null or empty");
+ }
+
+ byte[] kekBytes = loadKek(kekLabel);
+
+ try {
+ // Tink's AesGcmJce automatically generates a random IV and prepends it to the ciphertext
+ AesGcmJce aesgcm = new AesGcmJce(kekBytes);
+ byte[] wrappedBlob = aesgcm.encrypt(plainKey, new byte[0]);
+
+ WrappedKey wrapped = new WrappedKey(kekLabel, purpose, ALGORITHM, wrappedBlob, PROVIDER_NAME, new Date(),
+ null);
+
+ logger.debug("Wrapped {} key with KEK {}", purpose, kekLabel);
+ return wrapped;
+ } catch (Exception e) {
+ throw KMSException.wrapUnwrapFailed("Failed to wrap key: " + e.getMessage(), e);
+ } finally {
+ // Zeroize KEK
+ Arrays.fill(kekBytes, (byte) 0);
+ }
+ }
+
+ @Override
+ public byte[] unwrapKey(WrappedKey wrappedKey, Long hsmProfileId) throws KMSException {
+ // Database provider ignores hsmProfileId
+ return unwrapKey(wrappedKey);
+ }
+
+ @Override
+ public byte[] unwrapKey(WrappedKey wrappedKey) throws KMSException {
+ if (wrappedKey == null) {
+ throw KMSException.invalidParameter("Wrapped key cannot be null");
+ }
+
+ byte[] kekBytes = loadKek(wrappedKey.getKekId());
+
+ try {
+ AesGcmJce aesgcm = new AesGcmJce(kekBytes);
+ // Tink's decrypt expects [IV][ciphertext+tag] format (same as encrypt returns)
+ byte[] blob = wrappedKey.getWrappedKeyMaterial();
+ if (blob.length < GCM_IV_LENGTH + GCM_TAG_LENGTH) {
+ throw new KMSException(KMSException.ErrorType.WRAP_UNWRAP_FAILED,
+ "Invalid wrapped key format: too short");
+ }
+
+ byte[] plainKey = aesgcm.decrypt(blob, new byte[0]);
+
+ logger.debug("Unwrapped {} key with KEK {}", wrappedKey.getPurpose(), wrappedKey.getKekId());
+ return plainKey;
+
+ } catch (KMSException e) {
+ throw e;
+ } catch (Exception e) {
+ throw KMSException.wrapUnwrapFailed("Failed to unwrap key: " + e.getMessage(), e);
+ } finally {
+ // Zeroize KEK
+ Arrays.fill(kekBytes, (byte) 0);
+ }
+ }
+
+ @Override
+ public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits,
+ Long hsmProfileId) throws KMSException {
+ // Database provider ignores hsmProfileId
+ return generateAndWrapDek(purpose, kekLabel, keyBits);
+ }
+
+ @Override
+ public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits) throws KMSException {
+ if (keyBits != 128 && keyBits != 192 && keyBits != 256) {
+ throw KMSException.invalidParameter("DEK size must be 128, 192, or 256 bits");
+ }
+
+ byte[] dekBytes = new byte[keyBits / 8];
+ secureRandom.nextBytes(dekBytes);
+
+ try {
+ return wrapKey(dekBytes, purpose, kekLabel);
+ } finally {
+ // Zeroize DEK (wrapped version is in WrappedKey)
+ Arrays.fill(dekBytes, (byte) 0);
+ }
+ }
+
+ @Override
+ public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel,
+ Long targetHsmProfileId) throws KMSException {
+ // Database provider ignores targetHsmProfileId
+ return rewrapKey(oldWrappedKey, newKekLabel);
+ }
+
+ @Override
+ public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel) throws KMSException {
+ byte[] plainKey = unwrapKey(oldWrappedKey);
+ try {
+ return wrapKey(plainKey, oldWrappedKey.getPurpose(), newKekLabel);
+ } finally {
+ // Zeroize plaintext DEK
+ Arrays.fill(plainKey, (byte) 0);
+ }
+ }
+
+ @Override
+ public boolean healthCheck() throws KMSException {
+ try {
+ if (kekObjectDao == null) {
+ logger.error("KMSDatabaseKekObjectDao is not initialized");
+ return false;
+ }
+ return true;
+
+ } catch (Exception e) {
+ throw KMSException.healthCheckFailed("Health check failed: " + e.getMessage(), e);
+ }
+ }
+
+ private byte[] loadKek(String kekLabel) throws KMSException {
+ KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekLabel);
+
+ if (kekObject == null || kekObject.getRemoved() != null) {
+ throw KMSException.kekNotFound("KEK with label " + kekLabel + " not found");
+ }
+
+ try {
+ byte[] encryptedKekBytes = kekObject.getKeyMaterial();
+ if (encryptedKekBytes == null || encryptedKekBytes.length == 0) {
+ throw KMSException.kekNotFound("KEK value is empty for label " + kekLabel);
+ }
+
+ String encryptedKek = new String(encryptedKekBytes, StandardCharsets.UTF_8);
+ String kekBase64 = DBEncryptionUtil.decrypt(encryptedKek);
+ byte[] kekBytes = Base64.getDecoder().decode(kekBase64);
+
+ updateLastUsed(kekLabel);
+
+ return kekBytes;
+
+ } catch (IllegalArgumentException e) {
+ throw KMSException.kekOperationFailed("Invalid KEK encoding for label " + kekLabel, e);
+ } catch (Exception e) {
+ throw KMSException.kekOperationFailed("Failed to decrypt KEK for label " + kekLabel + ": " + e.getMessage(),
+ e);
+ }
+ }
+
+ private void updateLastUsed(String kekLabel) {
+ try {
+ KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekLabel);
+ if (kekObject != null && kekObject.getRemoved() == null) {
+ kekObject.setLastUsed(new Date());
+ kekObjectDao.update(kekObject.getId(), kekObject);
+ }
+ } catch (Exception e) {
+ logger.debug("Failed to update last used timestamp for KEK {}: {}", kekLabel, e.getMessage());
+ }
+ }
+
+ private String generateKekLabel(KeyPurpose purpose) {
+ return purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8);
+ }
+
+ @Override
+ public String getConfigComponentName() {
+ return DatabaseKMSProvider.class.getSimpleName();
+ }
+
+ @Override
+ public ConfigKey>[] getConfigKeys() {
+ return new ConfigKey>[0];
+ }
+}
diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java
new file mode 100644
index 000000000000..c1c91c9cef13
--- /dev/null
+++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java
@@ -0,0 +1,357 @@
+// 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.cloudstack.kms.provider.database;
+
+import com.cloud.utils.db.GenericDao;
+import org.apache.cloudstack.framework.kms.KeyPurpose;
+import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import javax.persistence.Temporal;
+import javax.persistence.TemporalType;
+import java.util.Date;
+import java.util.UUID;
+
+/**
+ * Database entity for KEK objects stored by the database KMS provider.
+ * Models PKCS#11 object attributes for cryptographic key storage.
+ *
+ * This table stores KEKs (Key Encryption Keys) in a PKCS#11-compatible format,
+ * allowing the database provider to mock PKCS#11 interface behavior.
+ */
+@Entity
+@Table(name = "kms_database_kek_objects")
+public class KMSDatabaseKekObjectVO {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "uuid", nullable = false)
+ private String uuid;
+
+ // PKCS#11 Object Class (CKA_CLASS)
+ @Column(name = "object_class", nullable = false, length = 32)
+ private String objectClass = "CKO_SECRET_KEY";
+
+ // PKCS#11 Label (CKA_LABEL) - human-readable identifier
+ @Column(name = "label", nullable = false, length = 255)
+ private String label;
+
+ // PKCS#11 ID (CKA_ID) - application-defined identifier
+ @Column(name = "object_id", length = 64)
+ private byte[] objectId;
+
+ // PKCS#11 Key Type (CKA_KEY_TYPE)
+ @Column(name = "key_type", nullable = false, length = 32)
+ private String keyType = "CKK_AES";
+
+ // PKCS#11 Key Value (CKA_VALUE) - encrypted KEK material
+ @Column(name = "key_material", nullable = false, length = 512)
+ private byte[] keyMaterial;
+
+ // PKCS#11 Boolean Attributes
+ @Column(name = "is_sensitive", nullable = false)
+ private Boolean isSensitive = true;
+
+ @Column(name = "is_extractable", nullable = false)
+ private Boolean isExtractable = false;
+
+ @Column(name = "is_token", nullable = false)
+ private Boolean isToken = true;
+
+ @Column(name = "is_private", nullable = false)
+ private Boolean isPrivate = true;
+
+ @Column(name = "is_modifiable", nullable = false)
+ private Boolean isModifiable = false;
+
+ @Column(name = "is_copyable", nullable = false)
+ private Boolean isCopyable = false;
+
+ @Column(name = "is_destroyable", nullable = false)
+ private Boolean isDestroyable = true;
+
+ @Column(name = "always_sensitive", nullable = false)
+ private Boolean alwaysSensitive = true;
+
+ @Column(name = "never_extractable", nullable = false)
+ private Boolean neverExtractable = true;
+
+ // Key Metadata
+ @Column(name = "purpose", nullable = false, length = 32)
+ @Enumerated(EnumType.STRING)
+ private KeyPurpose purpose;
+
+ @Column(name = "key_bits", nullable = false)
+ private Integer keyBits;
+
+ @Column(name = "algorithm", nullable = false, length = 64)
+ private String algorithm = "AES/GCM/NoPadding";
+
+ // PKCS#11 Validity Dates
+ @Column(name = "start_date")
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date startDate;
+
+ @Column(name = "end_date")
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date endDate;
+
+ // Lifecycle
+ @Column(name = GenericDao.CREATED_COLUMN, nullable = false)
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date created;
+
+ @Column(name = "last_used")
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date lastUsed;
+
+ @Column(name = GenericDao.REMOVED_COLUMN)
+ @Temporal(TemporalType.TIMESTAMP)
+ private Date removed;
+
+ /**
+ * Constructor for creating a new KEK object
+ *
+ * @param label PKCS#11 label (CKA_LABEL)
+ * @param purpose key purpose
+ * @param keyBits key size in bits
+ * @param keyMaterial encrypted key material (CKA_VALUE)
+ */
+ public KMSDatabaseKekObjectVO(String label, KeyPurpose purpose, Integer keyBits, byte[] keyMaterial) {
+ this();
+ this.label = label;
+ this.purpose = purpose;
+ this.keyBits = keyBits;
+ this.keyMaterial = keyMaterial;
+ this.objectId = label.getBytes(); // Use label as object ID by default
+ this.startDate = new Date();
+ }
+
+ public KMSDatabaseKekObjectVO() {
+ this.uuid = UUID.randomUUID().toString();
+ this.created = new Date();
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getUuid() {
+ return uuid;
+ }
+
+ public void setUuid(String uuid) {
+ this.uuid = uuid;
+ }
+
+ public String getObjectClass() {
+ return objectClass;
+ }
+
+ public void setObjectClass(String objectClass) {
+ this.objectClass = objectClass;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public void setLabel(String label) {
+ this.label = label;
+ }
+
+ public byte[] getObjectId() {
+ return objectId;
+ }
+
+ public void setObjectId(byte[] objectId) {
+ this.objectId = objectId;
+ }
+
+ public String getKeyType() {
+ return keyType;
+ }
+
+ public void setKeyType(String keyType) {
+ this.keyType = keyType;
+ }
+
+ public byte[] getKeyMaterial() {
+ return keyMaterial;
+ }
+
+ public void setKeyMaterial(byte[] keyMaterial) {
+ this.keyMaterial = keyMaterial;
+ }
+
+ public Boolean getIsSensitive() {
+ return isSensitive;
+ }
+
+ public void setIsSensitive(Boolean isSensitive) {
+ this.isSensitive = isSensitive;
+ }
+
+ public Boolean getIsExtractable() {
+ return isExtractable;
+ }
+
+ public void setIsExtractable(Boolean isExtractable) {
+ this.isExtractable = isExtractable;
+ }
+
+ public Boolean getIsToken() {
+ return isToken;
+ }
+
+ public void setIsToken(Boolean isToken) {
+ this.isToken = isToken;
+ }
+
+ public Boolean getIsPrivate() {
+ return isPrivate;
+ }
+
+ public void setIsPrivate(Boolean isPrivate) {
+ this.isPrivate = isPrivate;
+ }
+
+ public Boolean getIsModifiable() {
+ return isModifiable;
+ }
+
+ public void setIsModifiable(Boolean isModifiable) {
+ this.isModifiable = isModifiable;
+ }
+
+ public Boolean getIsCopyable() {
+ return isCopyable;
+ }
+
+ public void setIsCopyable(Boolean isCopyable) {
+ this.isCopyable = isCopyable;
+ }
+
+ public Boolean getIsDestroyable() {
+ return isDestroyable;
+ }
+
+ public void setIsDestroyable(Boolean isDestroyable) {
+ this.isDestroyable = isDestroyable;
+ }
+
+ public Boolean getAlwaysSensitive() {
+ return alwaysSensitive;
+ }
+
+ public void setAlwaysSensitive(Boolean alwaysSensitive) {
+ this.alwaysSensitive = alwaysSensitive;
+ }
+
+ public Boolean getNeverExtractable() {
+ return neverExtractable;
+ }
+
+ public void setNeverExtractable(Boolean neverExtractable) {
+ this.neverExtractable = neverExtractable;
+ }
+
+ public KeyPurpose getPurpose() {
+ return purpose;
+ }
+
+ public void setPurpose(KeyPurpose purpose) {
+ this.purpose = purpose;
+ }
+
+ public Integer getKeyBits() {
+ return keyBits;
+ }
+
+ public void setKeyBits(Integer keyBits) {
+ this.keyBits = keyBits;
+ }
+
+ public String getAlgorithm() {
+ return algorithm;
+ }
+
+ public void setAlgorithm(String algorithm) {
+ this.algorithm = algorithm;
+ }
+
+ public Date getStartDate() {
+ return startDate;
+ }
+
+ public void setStartDate(Date startDate) {
+ this.startDate = startDate;
+ }
+
+ public Date getEndDate() {
+ return endDate;
+ }
+
+ public void setEndDate(Date endDate) {
+ this.endDate = endDate;
+ }
+
+ public Date getCreated() {
+ return created;
+ }
+
+ public void setCreated(Date created) {
+ this.created = created;
+ }
+
+ public Date getLastUsed() {
+ return lastUsed;
+ }
+
+ public void setLastUsed(Date lastUsed) {
+ this.lastUsed = lastUsed;
+ }
+
+ public Date getRemoved() {
+ return removed;
+ }
+
+ public void setRemoved(Date removed) {
+ this.removed = removed;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("KMSDatabaseKekObject %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(
+ this, "id", "uuid", "label", "purpose", "keyBits", "objectClass", "keyType", "algorithm"));
+ }
+}
diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDao.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDao.java
new file mode 100644
index 000000000000..582c1179ec43
--- /dev/null
+++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDao.java
@@ -0,0 +1,61 @@
+// 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.cloudstack.kms.provider.database.dao;
+
+import com.cloud.utils.db.GenericDao;
+import org.apache.cloudstack.framework.kms.KeyPurpose;
+import org.apache.cloudstack.kms.provider.database.KMSDatabaseKekObjectVO;
+
+import java.util.List;
+
+/**
+ * DAO for KMSDatabaseKekObject entities
+ * Provides PKCS#11-like object storage operations for KEKs
+ */
+public interface KMSDatabaseKekObjectDao extends GenericDao For each configured HSM profile:
+ * If any HSM profile fails the health check, this method throws an exception.
+ * If no profiles are configured, returns true (nothing to check).
+ *
+ * @return true if all configured HSM profiles are healthy
+ * @throws KMSException with {@code HEALTH_CHECK_FAILED} if any HSM profile is unhealthy
+ */
+ @Override
+ public boolean healthCheck() throws KMSException {
+ if (sessionPools.isEmpty()) {
+ logger.debug("No HSM profiles configured for health check");
+ return true;
+ }
+
+ boolean allHealthy = true;
+ for (Long profileId : sessionPools.keySet()) {
+ if (!checkProfileHealth(profileId)) {
+ allHealthy = false;
+ }
+ }
+
+ if (!allHealthy) {
+ throw KMSException.healthCheckFailed("One or more HSM profiles failed health check", null);
+ }
+
+ return true;
+ }
+
+ private boolean checkProfileHealth(Long profileId) {
+ try {
+ Boolean result = executeWithSession(profileId, session -> {
+ try {
+ session.keyStore.size(); // Verify the HSM token is currently reachable
+ } catch (KeyStoreException e) {
+ return false;
+ }
+ return true;
+ });
+ logger.debug("Health check {} for HSM profile {}", result ? "passed" : "failed", profileId);
+ return result;
+ } catch (Exception e) {
+ logger.warn("Health check failed for HSM profile {}: {}", profileId, e.getMessage(), e);
+ return false;
+ }
+ }
+
+ @Override
+ public void invalidateProfileCache(Long profileId) {
+ HSMSessionPool pool = sessionPools.remove(profileId);
+ if (pool != null) {
+ pool.invalidate();
+ }
+ logger.info("Invalidated HSM session pool for profile {}", profileId);
+ }
+
+ Long resolveProfileId(String kekLabel) throws KMSException {
+ KMSKekVersionVO version = kmsKekVersionDao.findByKekLabel(kekLabel);
+ if (version != null && version.getHsmProfileId() != null) {
+ return version.getHsmProfileId();
+ }
+ throw new KMSException(KMSException.ErrorType.KEK_NOT_FOUND,
+ "Could not resolve HSM profile for KEK: " + kekLabel);
+ }
+
+ /**
+ * Executes an operation with a session from the pool, handling acquisition and release.
+ *
+ * @param hsmProfileId HSM profile ID
+ * @param operation Operation to execute with the session
+ * @return Result of the operation
+ * @throws KMSException if session acquisition fails or operation throws an exception
+ */
+ private
+ * Validates:
+ * Key operations supported:
+ * Configuration requirements:
+ * Error handling: PKCS#11 specific error codes are mapped to appropriate
+ * {@link KMSException.ErrorType} values for proper retry logic and error reporting.
+ */
+ private static class PKCS11Session {
+ private static final int IV_LENGTH = 16; // 128 bits for CBC mode
+
+ private KeyStore keyStore;
+ private Provider provider;
+ private String providerName;
+ private Path tempConfigFile;
+
+ /**
+ * Creates a new PKCS#11 session and connects to the HSM.
+ * The config map (including any sensitive values such as the PIN) is used only
+ * during connection setup and is not retained as a field.
+ *
+ * @param config HSM profile configuration containing library, slot/token_label, and pin
+ * @throws KMSException if connection fails or configuration is invalid
+ */
+ PKCS11Session(Map This method:
+ * Slot/token selection:
+ * PKCS#11 error codes are parsed from exception messages and mapped as follows:
+ * Checks performed:
+ *
+ * Note: Errors during cleanup are logged but do not throw exceptions
+ * to ensure cleanup continues even if some steps fail.
+ */
+ void close() {
+ try {
+ if (keyStore instanceof Closeable) {
+ ((Closeable) keyStore).close();
+ }
+
+ if (provider != null && providerName != null) {
+ try {
+ Security.removeProvider(providerName);
+ } catch (Exception e) {
+ logger.debug("Failed to remove provider {}: {}", providerName, e.getMessage());
+ }
+ }
+
+ if (tempConfigFile != null) {
+ try {
+ Files.deleteIfExists(tempConfigFile);
+ } catch (IOException e) {
+ logger.debug("Failed to delete temporary config file {}: {}", tempConfigFile, e.getMessage());
+ }
+ }
+ } catch (Exception e) {
+ logger.warn("Error during session close: {}", e.getMessage());
+ } finally {
+ keyStore = null;
+ provider = null;
+ providerName = null;
+ tempConfigFile = null;
+ }
+ }
+
+ /**
+ * Generates an AES key directly in the HSM with the specified label.
+ *
+ *
+ * This method generates the key natively inside the HSM using a
+ * {@link KeyGenerator} configured with the PKCS#11 provider, so the key
+ * material never leaves the HSM boundary. The returned PKCS#11-native key
+ * reference ({@code P11Key}) is then stored in the KeyStore under the
+ * requested label.
+ *
+ *
+ * Using {@code KeyGenerator} with the HSM provider is required for
+ * HSMs such as NetHSM that do not support importing raw secret-key bytes
+ * via {@code KeyStore.setKeyEntry()}. By generating the key on the HSM first,
+ * the value passed to {@code setKeyEntry()} is already a PKCS#11 token object,
+ * so no raw-bytes import is attempted.
+ *
+ *
+ * Once stored, the key:
+ * Uses AES-CBC with PKCS5Padding (FIPS 197 + NIST SP 800-38A):
+ * Security: The plaintext DEK should be zeroized by the caller after wrapping.
+ *
+ * @param plainDek Plaintext DEK to wrap (will be encrypted)
+ * @param kekLabel Label of the KEK stored in the HSM
+ * @return Wrapped key blob: [IV][ciphertext]
+ * @throws KMSException with appropriate ErrorType:
+ *
+ * Uses AES-CBC with PKCS5Padding. Expected format: [IV (16 bytes)][ciphertext].
+ *
+ *
+ * Security: The returned plaintext DEK must be zeroized by the caller after
+ * use.
+ *
+ * @param wrappedBlob Wrapped DEK blob (IV + ciphertext)
+ * @param kekLabel Label of the KEK stored in the HSM
+ * @return Plaintext DEK
+ * @throws KMSException with appropriate ErrorType:
+ * Warning: Deleting a KEK makes all DEKs wrapped with that KEK
+ * permanently unrecoverable. This operation should be used with extreme caution.
+ *
+ * @param label Label of the key to delete
+ * @throws KMSException with appropriate ErrorType:
+ *
+ *
+ *
+ *
+ *
+ *
+ * @param config Configuration map from HSM profile details
+ * @throws KMSException with {@code INVALID_PARAMETER} if validation fails
+ */
+ @Override
+ public void validateProfileConfig(Map
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @throws KMSException with appropriate ErrorType:
+ *
+ *
+ */
+ private void connect(Map
+ *
+ *
+ * @param e The exception to map
+ * @param context Context description for the error message
+ * @throws KMSException with appropriate ErrorType and detailed message
+ */
+ private void handlePKCS11Exception(Exception e, String context) throws KMSException {
+ String errorMsg = e.getMessage();
+ if (errorMsg == null) {
+ errorMsg = e.getClass().getSimpleName();
+ }
+ logger.warn("PKCS#11 error: {} - {}", errorMsg, context, e);
+
+ if (errorMsg.contains("CKR_PIN_INCORRECT") || errorMsg.contains("PIN_INCORRECT")) {
+ throw new KMSException(KMSException.ErrorType.AUTHENTICATION_FAILED,
+ context + ": Incorrect PIN", e);
+ } else if (errorMsg.contains("CKR_SLOT_ID_INVALID") || errorMsg.contains("SLOT_ID_INVALID")) {
+ throw KMSException.invalidParameter(context + ": Invalid slot ID");
+ } else if (errorMsg.contains("CKR_KEY_NOT_FOUND") || errorMsg.contains("KEY_NOT_FOUND")) {
+ throw KMSException.kekNotFound(context + ": Key not found");
+ } else if (errorMsg.contains("CKR_DEVICE_ERROR") || errorMsg.contains("DEVICE_ERROR")) {
+ throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED,
+ context + ": HSM device error", e);
+ } else if (errorMsg.contains("CKR_SESSION_HANDLE_INVALID") || errorMsg.contains("SESSION_HANDLE_INVALID")) {
+ throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED,
+ context + ": Invalid session handle", e);
+ } else if (errorMsg.contains("CKR_KEY_ALREADY_EXISTS") || errorMsg.contains("KEY_ALREADY_EXISTS")) {
+ throw KMSException.keyAlreadyExists(context);
+ } else if (e instanceof KeyStoreException) {
+ throw new KMSException(KMSException.ErrorType.WRAP_UNWRAP_FAILED,
+ context + ": " + errorMsg, e);
+ } else {
+ throw new KMSException(KMSException.ErrorType.KEK_OPERATION_FAILED,
+ context + ": " + errorMsg, e);
+ }
+ }
+
+ /**
+ * Validates that the PKCS#11 session is still active and connected to the HSM.
+ *
+ *
+ *
+ *
+ * @return true if session is valid and HSM is accessible, false otherwise
+ */
+ boolean isValid() {
+ try {
+ if (keyStore == null) {
+ return false;
+ }
+
+ if (provider == null || Security.getProvider(provider.getName()) == null) {
+ return false;
+ }
+
+ keyStore.size();
+ return true;
+ } catch (Exception e) {
+ logger.debug("Session validation failed: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Closes the PKCS#11 session and cleans up resources.
+ *
+ *
+ *
+ *
+ * @param label Unique label for the key in the HSM
+ * @param keyBits Key size in bits (128, 192, or 256)
+ * @param purpose Key purpose (for logging/auditing)
+ * @return The label of the generated key
+ * @throws KMSException if generation fails or key already exists
+ */
+ String generateKey(String label, int keyBits, KeyPurpose purpose) throws KMSException {
+ validateKeySize(keyBits);
+
+ try {
+ // Check if key with this label already exists
+ if (keyStore.containsAlias(label)) {
+ throw KMSException.keyAlreadyExists("Key with label '" + label + "' already exists in HSM");
+ }
+
+ // Generate the AES key natively inside the HSM using the PKCS#11 provider.
+ // This avoids importing raw key bytes into the HSM, which is not supported
+ // by all HSMs (e.g. NetHSM rejects SecretKeySpec via storeSkey()).
+ // The resulting key is a PKCS#11-native P11Key that lives inside the token.
+ KeyGenerator keyGen = KeyGenerator.getInstance("AES", provider);
+ keyGen.init(keyBits);
+ SecretKey hsmKey = keyGen.generateKey();
+
+ // Associate the HSM-generated key with the requested label by storing
+ // it in the PKCS#11 KeyStore. Because hsmKey is already a P11Key
+ // (not a software SecretKeySpec), P11KeyStore.storeSkey() stores it
+ // as a persistent token object (CKA_TOKEN=true) with CKA_LABEL=label
+ // without attempting any raw-bytes conversion.
+ keyStore.setKeyEntry(label, hsmKey, null, null);
+
+ logger.info("Generated AES-{} key '{}' in HSM (purpose: {})",
+ keyBits, label, purpose);
+ return label;
+
+ } catch (KeyStoreException e) {
+ handlePKCS11Exception(e, "Failed to store key in HSM KeyStore");
+ } catch (NoSuchAlgorithmException e) {
+ handlePKCS11Exception(e, "AES KeyGenerator not available via PKCS#11 provider");
+ } catch (Exception e) {
+ String errorMsg = e.getMessage();
+ if (errorMsg != null && (errorMsg.contains("CKR_OBJECT_HANDLE_INVALID")
+ || errorMsg.contains("already exists"))) {
+ throw KMSException.keyAlreadyExists("Key with label '" + label + "' already exists in HSM");
+ } else {
+ handlePKCS11Exception(e, "Failed to generate key in HSM");
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Validates that the key size is one of the supported AES key sizes.
+ *
+ * @param keyBits Key size in bits
+ * @throws KMSException if key size is invalid
+ */
+ private void validateKeySize(int keyBits) throws KMSException {
+ if (Arrays.stream(VALID_KEY_SIZES).noneMatch(size -> size == keyBits)) {
+ throw KMSException.invalidParameter("Key size must be 128, 192, or 256 bits");
+ }
+ }
+
+ /**
+ * Wraps (encrypts) a plaintext DEK using a KEK stored in the HSM.
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+ byte[] wrapKey(byte[] plainDek, String kekLabel) throws KMSException {
+ if (plainDek == null || plainDek.length == 0) {
+ throw KMSException.invalidParameter("Plain DEK cannot be null or empty");
+ }
+
+ SecretKey kek = getKekFromKeyStore(kekLabel);
+ try {
+ byte[] iv = new byte[IV_LENGTH];
+ new SecureRandom().nextBytes(iv);
+
+ Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM, provider);
+ cipher.init(Cipher.ENCRYPT_MODE, kek, new IvParameterSpec(iv));
+ byte[] ciphertext = cipher.doFinal(plainDek);
+
+ byte[] result = new byte[IV_LENGTH + ciphertext.length];
+ System.arraycopy(iv, 0, result, 0, IV_LENGTH);
+ System.arraycopy(ciphertext, 0, result, IV_LENGTH, ciphertext.length);
+
+ logger.debug("Wrapped key with KEK '{}' using AES-CBC", kekLabel);
+ return result;
+ } catch (IllegalBlockSizeException | BadPaddingException | InvalidKeyException e) {
+ handlePKCS11Exception(e, "Invalid key or data for wrapping");
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+ handlePKCS11Exception(e, "AES-CBC not supported by HSM");
+ } catch (InvalidAlgorithmParameterException e) {
+ handlePKCS11Exception(e, "Invalid IV for CBC mode");
+ } catch (Exception e) {
+ handlePKCS11Exception(e, "Failed to wrap key with HSM");
+ } finally {
+ kek = null;
+ }
+ return null;
+ }
+
+ /**
+ * Retrieves a KEK (Key Encryption Key) from the HSM KeyStore.
+ *
+ * @param kekLabel Label of the KEK to retrieve
+ * @return SecretKey representing the KEK
+ * @throws KMSException if KEK is not found or not accessible
+ */
+ private SecretKey getKekFromKeyStore(String kekLabel) throws KMSException {
+ try {
+ Key key = keyStore.getKey(kekLabel, null);
+ if (key == null) {
+ throw KMSException.kekNotFound("KEK with label '" + kekLabel + "' not found in HSM");
+ }
+ if (!(key instanceof SecretKey)) {
+ throw KMSException.kekNotFound("Key with label '" + kekLabel + "' is not a secret key");
+ }
+ return (SecretKey) key;
+ } catch (UnrecoverableKeyException e) {
+ throw KMSException.kekNotFound("KEK with label '" + kekLabel + "' is not accessible");
+ } catch (NoSuchAlgorithmException e) {
+ handlePKCS11Exception(e, "Algorithm not supported");
+ } catch (KeyStoreException e) {
+ handlePKCS11Exception(e, "Failed to retrieve KEK from HSM");
+ }
+ return null;
+ }
+
+ /**
+ * Unwraps (decrypts) a wrapped DEK using a KEK stored in the HSM.
+ *
+ *
+ *
+ */
+ byte[] unwrapKey(byte[] wrappedBlob, String kekLabel) throws KMSException {
+ if (wrappedBlob == null || wrappedBlob.length == 0) {
+ throw KMSException.invalidParameter("Wrapped blob cannot be null or empty");
+ }
+
+ // Minimum size: IV (16 bytes) + at least one AES block (16 bytes)
+ if (wrappedBlob.length < IV_LENGTH + 16) {
+ throw KMSException.invalidParameter("Wrapped blob too short: expected at least " +
+ (IV_LENGTH + 16) + " bytes");
+ }
+
+ SecretKey kek = getKekFromKeyStore(kekLabel);
+ try {
+ byte[] iv = new byte[IV_LENGTH];
+ System.arraycopy(wrappedBlob, 0, iv, 0, IV_LENGTH);
+ byte[] ciphertext = new byte[wrappedBlob.length - IV_LENGTH];
+ System.arraycopy(wrappedBlob, IV_LENGTH, ciphertext, 0, ciphertext.length);
+
+ Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM, provider);
+ cipher.init(Cipher.DECRYPT_MODE, kek, new IvParameterSpec(iv));
+ byte[] plainDek = cipher.doFinal(ciphertext);
+
+ logger.debug("Unwrapped key with KEK '{}' using AES-CBC", kekLabel);
+ return plainDek;
+ } catch (BadPaddingException e) {
+ throw KMSException.wrapUnwrapFailed(
+ "Decryption failed: wrapped key may be corrupted or KEK is incorrect", e);
+ } catch (IllegalBlockSizeException | InvalidKeyException e) {
+ handlePKCS11Exception(e, "Invalid key or data for unwrapping");
+ } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
+ handlePKCS11Exception(e, "AES-CBC not supported by HSM");
+ } catch (InvalidAlgorithmParameterException e) {
+ handlePKCS11Exception(e, "Invalid IV for CBC mode");
+ } catch (Exception e) {
+ handlePKCS11Exception(e, "Failed to unwrap key with HSM");
+ } finally {
+ kek = null;
+ }
+ return null;
+ }
+
+ /**
+ * Deletes a key from the HSM.
+ *
+ *
+ *
+ */
+ void deleteKey(String label) throws KMSException {
+ try {
+ if (!keyStore.containsAlias(label)) {
+ throw KMSException.kekNotFound("Key with label '" + label + "' not found in HSM");
+ }
+
+ keyStore.deleteEntry(label);
+
+ logger.debug("Deleted key '{}' from HSM", label);
+ } catch (KeyStoreException e) {
+ String errorMsg = e.getMessage();
+ if (errorMsg != null && errorMsg.contains("not found")) {
+ throw KMSException.kekNotFound("Key with label '" + label + "' not found in HSM");
+ } else if (errorMsg != null && errorMsg.contains("in use")) {
+ throw KMSException.kekOperationFailed(
+ "Key with label '" + label + "' is in use and cannot be deleted");
+ } else {
+ handlePKCS11Exception(e, "Failed to delete key from HSM");
+ }
+ } catch (Exception e) {
+ handlePKCS11Exception(e, "Failed to delete key from HSM");
+ }
+ }
+
+ /**
+ * Checks if a key with the given label exists and is accessible in the HSM.
+ *
+ * @param label Label of the key to check
+ * @return true if key exists and is accessible, false otherwise
+ * @throws KMSException only for unexpected errors (KeyStoreException, etc.)
+ * Returns false for expected cases (key not found, unrecoverable key)
+ */
+ boolean checkKeyExists(String label) throws KMSException {
+ try {
+ Key key = keyStore.getKey(label, null);
+ return key != null;
+ } catch (KeyStoreException e) {
+ logger.debug("KeyStore error while checking key existence: {}", e.getMessage());
+ return false;
+ } catch (UnrecoverableKeyException e) {
+ // Key exists but is not accessible (might be a different key type)
+ logger.debug("Key '{}' exists but is not accessible: {}", label, e.getMessage());
+ return false;
+ } catch (NoSuchAlgorithmException e) {
+ logger.debug("Algorithm error while checking key existence: {}", e.getMessage());
+ return false;
+ } catch (Exception e) {
+ logger.debug("Unexpected error while checking key existence: {}", e.getMessage());
+ return false;
+ }
+ }
+ }
+}
diff --git a/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/module.properties b/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/module.properties
new file mode 100644
index 000000000000..aa7a51607577
--- /dev/null
+++ b/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/module.properties
@@ -0,0 +1,21 @@
+#
+# 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.
+#
+
+name=pkcs11-kms
+parent=kms
diff --git a/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/spring-pkcs11-kms-context.xml b/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/spring-pkcs11-kms-context.xml
new file mode 100644
index 000000000000..cdd29d2cf244
--- /dev/null
+++ b/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/spring-pkcs11-kms-context.xml
@@ -0,0 +1,32 @@
+
+
ssh -i [ssh_key] -p [port_number] cloud@[public_ip_address] ssh_key: points to the ssh private key file corresponding to the key that was associated while creating the Kubernetes Cluster. If no ssh key was provided during Kubernetes cluster creation, use the ssh private key of the management server. port_number: can be obtained from the Port Forwarding Tab (Public Port column)",
@@ -1481,6 +1498,7 @@
"label.lbruleid": "Load balancer ID",
"label.lbtype": "Load balancer type",
"label.ldap": "LDAP",
+"label.library": "Library",
"label.ldapdomain": "LDAP Domain",
"label.ldap.configuration": "LDAP Configuration",
"label.ldap.group.name": "LDAP Group",
@@ -1620,6 +1638,7 @@
"label.migrate.instance.specific.storages": "Migrate volume(s) of the Instance to specific primary storages",
"label.migrate.systemvm.to": "Migrate System VM to",
"label.migrate.volume": "Migrate Volume",
+"label.migrate.volume.to.kms": "Migrate Volume Encryption to KMS",
"message.memory.usage.info.hypervisor.additionals": "The data shown may not reflect the actual memory usage if the Instance does not have the additional hypervisor tools installed",
"message.memory.usage.info.negative.value": "If the Instance's memory usage cannot be obtained from the hypervisor, the lines for free memory in the raw data graph and memory usage in the percentage graph will be disabled",
"message.migrate.volume.tooltip": "Volume can be migrated to any suitable storage pool. Admin has to choose the appropriate disk offering to replace, that supports the new storage pool",
@@ -1896,6 +1915,7 @@
"label.physicalnetworkid": "Physical Network",
"label.physicalnetworkname": "Physical Network name",
"label.physicalsize": "Physical size",
+"label.pin": "PIN",
"label.ping.path": "Ping path",
"label.pkcs.private.certificate": "PKCS#8 private certificate",
"label.plannermode": "Planner mode",
@@ -2482,6 +2502,7 @@
"label.suspend.project": "Suspend Project",
"label.switch.type": "Switch type",
"label.sync.storage": "Sync Storage Pool",
+"label.system": "System",
"label.system.ip.pool": "System Pool",
"label.system.offering": "System Offering",
"label.system.offerings": "System Offerings",
@@ -2953,9 +2974,11 @@
"message.action.delete.guest.os": "Please confirm that you want to delete this guest os. System defined entry cannot be deleted.",
"message.action.delete.guest.os.category": "Please confirm that you want to delete this guest os category.",
"message.action.delete.guest.os.hypervisor.mapping": "Please confirm that you want to delete this guest os hypervisor mapping. System defined entry cannot be deleted.",
+"message.action.delete.hsm.profile": "Please confirm that you want to delete this HSM profile.",
"message.action.delete.instance.group": "Please confirm that you want to delete the Instance group.",
"message.action.delete.interface.static.route": "Please confirm that you want to remove this interface Static Route?",
"message.action.delete.iso": "Please confirm that you want to delete this ISO.",
+"message.action.delete.kms.key": "Please confirm that you want to delete this KMS key.",
"message.action.delete.network": "Please confirm that you want to delete this Network.",
"message.action.delete.network.static.route": "Please confirm that you want to remove this Network Static Route",
"message.action.delete.nexusvswitch": "Please confirm that you want to delete this nexus 1000v",
@@ -3686,6 +3709,8 @@
"message.migrate.volume.failed": "Migrating volume failed.",
"message.migrate.volume.pool.auto.assign": "Primary storage for the volume will be automatically chosen based on the suitability and Instance destination",
"message.migrate.volume.processing": "Migrating volume...",
+"message.action.migrate.volume.to.kms": "Please confirm that you want to migrate this volume's passphrase encryption to KMS. This operation re-encrypts the volume key using the selected KMS key and cannot be undone.",
+"message.action.migrate.volumes.to.kms": "Please confirm that you want to migrate volumes to KMS encryption. This operation re-encrypts volume keys using the selected KMS key and cannot be undone.",
"message.migrate.with.storage": "Specify storage pool for volumes of the Instance.",
"message.migrating.failed": "Migration failed.",
"message.migrating.processing": "Migration in progress for",
@@ -4203,5 +4228,6 @@
"Compute*Month": "Compute * Month",
"GB*Month": "GB * Month",
"IP*Month": "IP * Month",
-"Policy*Month": "Policy * Month"
+"Policy*Month": "Policy * Month",
+"message.kms.key.optional": "Optional: Select a KMS key for encryption. If not selected, legacy passphrase encryption will be used."
}
diff --git a/ui/src/components/view/DetailsTab.vue b/ui/src/components/view/DetailsTab.vue
index 4145eeb9be6d..93af6b155718 100644
--- a/ui/src/components/view/DetailsTab.vue
+++ b/ui/src/components/view/DetailsTab.vue
@@ -189,7 +189,7 @@