sc = allFieldSearch.create();
+ sc.setParameters("kekVersionId", kekVersionId);
+ Integer count = getCount(sc);
+ return count != null ? count.longValue() : 0L;
+ }
+}
diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml
index edc14d9fa0cc..cbc74c65f963 100644
--- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml
+++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml
@@ -312,4 +312,9 @@
+
+
+
+
+
diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql
index d69b524b85d9..863e13104d07 100644
--- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql
+++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql
@@ -114,3 +114,168 @@ CALL `cloud`.`IDEMPOTENT_UPDATE_API_PERMISSION`('Resource Admin', 'deleteUserKey
-- Add conserve mode for VPC offerings
CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tinyint(1) unsigned NULL DEFAULT 0 COMMENT ''True if the VPC offering is IP conserve mode enabled, allowing public IP services to be used across multiple VPC tiers'' ');
+
+-- KMS HSM Profiles (Generic table for PKCS#11, KMIP, etc.)
+-- Scoped to account (user-provided) or global/zone (admin-provided)
+CREATE TABLE IF NOT EXISTS `cloud`.`kms_hsm_profiles` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `uuid` VARCHAR(40) NOT NULL,
+ `name` VARCHAR(255) NOT NULL,
+ `protocol` VARCHAR(32) NOT NULL COMMENT 'PKCS11, KMIP, AWS_KMS, etc.',
+
+ -- Scoping
+ `account_id` BIGINT UNSIGNED COMMENT 'null = admin-provided (available to all accounts)',
+ `domain_id` BIGINT UNSIGNED COMMENT 'null = zone/global scope',
+ `zone_id` BIGINT UNSIGNED COMMENT 'null = global scope',
+
+ -- Metadata
+ `vendor_name` VARCHAR(64) COMMENT 'HSM vendor (Thales, AWS, SoftHSM, etc.)',
+ `enabled` BOOLEAN NOT NULL DEFAULT TRUE,
+ `system` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'System profile (globally available, root admin only)',
+ `created` DATETIME NOT NULL,
+ `removed` DATETIME,
+
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_uuid` (`uuid`),
+ UNIQUE KEY `uk_account_name` (`account_id`, `name`, `removed`),
+ INDEX `idx_protocol_enabled` (`protocol`, `enabled`, `removed`),
+ INDEX `idx_scoping` (`account_id`, `domain_id`, `zone_id`, `removed`),
+ CONSTRAINT `fk_kms_hsm_profiles__account_id` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_kms_hsm_profiles__domain_id` FOREIGN KEY (`domain_id`) REFERENCES `domain`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_kms_hsm_profiles__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='HSM profiles for KMS providers';
+
+-- KMS HSM Profile Details (Protocol-specific configuration)
+CREATE TABLE IF NOT EXISTS `cloud`.`kms_hsm_profile_details` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `profile_id` BIGINT UNSIGNED NOT NULL COMMENT 'HSM profile ID',
+ `name` VARCHAR(255) NOT NULL COMMENT 'Config key (e.g. library_path, endpoint, pin, cert_content)',
+ `value` TEXT NOT NULL COMMENT 'Config value (encrypted if sensitive)',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_profile_name` (`profile_id`, `name`),
+ CONSTRAINT `fk_kms_hsm_profile_details__profile_id` FOREIGN KEY (`profile_id`) REFERENCES `kms_hsm_profiles`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Details for HSM profiles (key-value configuration)';
+
+-- KMS Keys (Key Encryption Key Metadata)
+-- Account-scoped KEKs for envelope encryption
+CREATE TABLE IF NOT EXISTS `cloud`.`kms_keys` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Unique ID',
+ `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID - user-facing identifier',
+ `name` VARCHAR(255) NOT NULL COMMENT 'User-friendly name',
+ `description` VARCHAR(1024) COMMENT 'User description',
+ `kek_label` VARCHAR(255) NOT NULL COMMENT 'Provider-specific KEK label/ID',
+ `purpose` VARCHAR(32) NOT NULL COMMENT 'Key purpose (VOLUME_ENCRYPTION, TLS_CERT)',
+ `account_id` BIGINT UNSIGNED NOT NULL COMMENT 'Owning account',
+ `domain_id` BIGINT UNSIGNED NOT NULL COMMENT 'Owning domain',
+ `zone_id` BIGINT UNSIGNED NOT NULL COMMENT 'Zone where key is valid',
+ `algorithm` VARCHAR(64) NOT NULL DEFAULT 'AES/GCM/NoPadding' COMMENT 'Encryption algorithm',
+ `key_bits` INT NOT NULL DEFAULT 256 COMMENT 'Key size in bits',
+ `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Whether the key is enabled for new cryptographic operations',
+ `hsm_profile_id` BIGINT UNSIGNED NOT NULL COMMENT 'Current HSM profile ID for this key',
+ `created` DATETIME NOT NULL COMMENT 'Creation timestamp',
+ `removed` DATETIME COMMENT 'Removal timestamp for soft delete',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_uuid` (`uuid`),
+ INDEX `idx_account_purpose` (`account_id`, `purpose`, `enabled`),
+ INDEX `idx_domain_purpose` (`domain_id`, `purpose`, `enabled`),
+ INDEX `idx_zone_enabled` (`zone_id`, `enabled`),
+ CONSTRAINT `fk_kms_keys__account_id` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_kms_keys__domain_id` FOREIGN KEY (`domain_id`) REFERENCES `domain`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_kms_keys__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_kms_keys__hsm_profile_id` FOREIGN KEY (`hsm_profile_id`) REFERENCES `kms_hsm_profiles`(`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KMS Key (KEK) metadata - account-scoped keys for envelope encryption';
+
+-- KMS KEK Versions (multiple KEKs per KMS key for gradual rotation)
+-- Supports multiple KEK versions per logical KMS key during rotation
+CREATE TABLE IF NOT EXISTS `cloud`.`kms_kek_versions` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Unique ID',
+ `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID',
+ `kms_key_id` BIGINT UNSIGNED NOT NULL COMMENT 'Reference to kms_keys table',
+ `version_number` INT NOT NULL COMMENT 'Version number (1, 2, 3, ...)',
+ `kek_label` VARCHAR(255) NOT NULL COMMENT 'Provider-specific KEK label/ID for this version',
+ `status` VARCHAR(32) NOT NULL DEFAULT 'Active' COMMENT 'Active, Previous, Archived',
+ `hsm_profile_id` BIGINT UNSIGNED COMMENT 'HSM profile where this KEK version is stored',
+ `created` DATETIME NOT NULL COMMENT 'Creation timestamp',
+ `removed` DATETIME COMMENT 'Removal timestamp for soft delete',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_uuid` (`uuid`),
+ UNIQUE KEY `uk_kms_key_version` (`kms_key_id`, `version_number`, `removed`),
+ INDEX `idx_kms_key_status` (`kms_key_id`, `status`, `removed`),
+ INDEX `idx_kek_label` (`kek_label`),
+ CONSTRAINT `fk_kms_kek_versions__kms_key_id` FOREIGN KEY (`kms_key_id`) REFERENCES `kms_keys`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_kms_kek_versions__hsm_profile_id` FOREIGN KEY (`hsm_profile_id`) REFERENCES `kms_hsm_profiles`(`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KEK versions for a KMS key - supports gradual rotation';
+
+-- KMS Wrapped Keys (Data Encryption Keys)
+-- Generic table for wrapped DEKs - references kms_keys for metadata and kek_versions for specific KEK version
+CREATE TABLE IF NOT EXISTS `cloud`.`kms_wrapped_key` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Unique ID',
+ `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID',
+ `kms_key_id` BIGINT UNSIGNED COMMENT 'Reference to kms_keys table',
+ `kek_version_id` BIGINT UNSIGNED COMMENT 'Reference to kms_kek_versions table',
+ `zone_id` BIGINT UNSIGNED NOT NULL COMMENT 'Zone ID for zone-scoped keys',
+ `wrapped_blob` VARBINARY(4096) NOT NULL COMMENT 'Encrypted DEK material',
+ `created` DATETIME NOT NULL COMMENT 'Creation timestamp',
+ `removed` DATETIME COMMENT 'Removal timestamp for soft delete',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_uuid` (`uuid`),
+ INDEX `idx_kms_key_id` (`kms_key_id`, `removed`),
+ INDEX `idx_kek_version_id` (`kek_version_id`, `removed`),
+ INDEX `idx_zone_id` (`zone_id`, `removed`),
+ CONSTRAINT `fk_kms_wrapped_key__kms_key_id` FOREIGN KEY (`kms_key_id`) REFERENCES `kms_keys`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_kms_wrapped_key__kek_version_id` FOREIGN KEY (`kek_version_id`) REFERENCES `kms_kek_versions`(`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_kms_wrapped_key__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KMS wrapped encryption keys (DEKs) - references kms_keys for KEK metadata and kek_versions for specific version';
+
+-- Add KMS key reference to volumes table (which KMS key was used)
+CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.volumes', 'kms_key_id', 'BIGINT UNSIGNED COMMENT ''KMS key ID used for volume encryption''');
+CALL `cloud`.`IDEMPOTENT_ADD_FOREIGN_KEY`('cloud.volumes', 'fk_volumes__kms_key_id', '(kms_key_id)', '`kms_keys`(`id`)');
+
+-- Add KMS wrapped key reference to volumes table
+CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.volumes', 'kms_wrapped_key_id', 'BIGINT UNSIGNED COMMENT ''KMS wrapped key ID for volume encryption''');
+CALL `cloud`.`IDEMPOTENT_ADD_FOREIGN_KEY`('cloud.volumes', 'fk_volumes__kms_wrapped_key_id', '(kms_wrapped_key_id)', '`kms_wrapped_key`(`id`)');
+
+-- KMS Database Provider KEK Objects (PKCS#11-like object storage)
+-- Stores KEKs for the database KMS provider in a PKCS#11-compatible format
+CREATE TABLE IF NOT EXISTS `cloud`.`kms_database_kek_objects` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Object handle (PKCS#11 CKA_HANDLE)',
+ `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID',
+ -- PKCS#11 Object Class (CKA_CLASS)
+ `object_class` VARCHAR(32) NOT NULL DEFAULT 'CKO_SECRET_KEY' COMMENT 'PKCS#11 object class (CKO_SECRET_KEY, CKO_PRIVATE_KEY, etc.)',
+ -- PKCS#11 Label (CKA_LABEL) - human-readable identifier
+ `label` VARCHAR(255) NOT NULL COMMENT 'PKCS#11 label (CKA_LABEL) - human-readable identifier',
+ -- PKCS#11 ID (CKA_ID) - application-defined identifier
+ `object_id` VARBINARY(64) COMMENT 'PKCS#11 object ID (CKA_ID) - application-defined identifier',
+ -- Key Type (CKA_KEY_TYPE)
+ `key_type` VARCHAR(32) NOT NULL DEFAULT 'CKK_AES' COMMENT 'PKCS#11 key type (CKK_AES, CKK_RSA, etc.)',
+ -- Key Material (CKA_VALUE) - encrypted KEK material
+ `key_material` VARBINARY(512) NOT NULL COMMENT 'PKCS#11 key value (CKA_VALUE) - encrypted KEK material',
+ -- Key Attributes (PKCS#11 boolean attributes)
+ `is_sensitive` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_SENSITIVE - key material is sensitive',
+ `is_extractable` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'PKCS#11 CKA_EXTRACTABLE - key can be extracted',
+ `is_token` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_TOKEN - object is on token (persistent)',
+ `is_private` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_PRIVATE - object is private',
+ `is_modifiable` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'PKCS#11 CKA_MODIFIABLE - object can be modified',
+ `is_copyable` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'PKCS#11 CKA_COPYABLE - object can be copied',
+ `is_destroyable` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_DESTROYABLE - object can be destroyed',
+ `always_sensitive` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_ALWAYS_SENSITIVE - key was always sensitive',
+ `never_extractable` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_NEVER_EXTRACTABLE - key was never extractable',
+ -- Key Metadata
+ `purpose` VARCHAR(32) NOT NULL COMMENT 'Key purpose (VOLUME_ENCRYPTION, TLS_CERT)',
+ `key_bits` INT NOT NULL COMMENT 'Key size in bits (128, 192, 256)',
+ `algorithm` VARCHAR(64) NOT NULL DEFAULT 'AES/GCM/NoPadding' COMMENT 'Encryption algorithm',
+ -- Validity Dates (PKCS#11 CKA_START_DATE, CKA_END_DATE)
+ `start_date` DATETIME COMMENT 'PKCS#11 CKA_START_DATE - key validity start',
+ `end_date` DATETIME COMMENT 'PKCS#11 CKA_END_DATE - key validity end',
+ -- Lifecycle
+ `created` DATETIME NOT NULL COMMENT 'Creation timestamp',
+ `last_used` DATETIME COMMENT 'Last usage timestamp',
+ `removed` DATETIME COMMENT 'Removal timestamp for soft delete',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_uuid` (`uuid`),
+ UNIQUE KEY `uk_label_removed` (`label`, `removed`),
+ INDEX `idx_purpose_removed` (`purpose`, `removed`),
+ INDEX `idx_key_type` (`key_type`, `removed`),
+ INDEX `idx_object_class` (`object_class`, `removed`),
+ INDEX `idx_created` (`created`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KMS Database Provider KEK Objects - PKCS#11-like object storage for KEKs';
diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql
index ffeb93e8fa7a..8ba7e5c6df77 100644
--- a/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql
+++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql
@@ -40,6 +40,10 @@ SELECT
`volumes`.`chain_info` AS `chain_info`,
`volumes`.`external_uuid` AS `external_uuid`,
`volumes`.`encrypt_format` AS `encrypt_format`,
+ `volumes`.`kms_key_id` AS `kms_key_id`,
+ `kms_keys`.`uuid` AS `kms_key_uuid`,
+ `kms_keys`.`name` AS `kms_key_name`,
+ `volumes`.`kms_wrapped_key_id` AS `kms_wrapped_key_id`,
`volumes`.`delete_protection` AS `delete_protection`,
`account`.`id` AS `account_id`,
`account`.`uuid` AS `account_uuid`,
@@ -116,7 +120,7 @@ SELECT
`resource_tag_domain`.`uuid` AS `tag_domain_uuid`,
`resource_tag_domain`.`name` AS `tag_domain_name`
FROM
- ((((((((((((((((((`volumes`
+ (((((((((((((((((((`volumes`
JOIN `account`ON
((`volumes`.`account_id` = `account`.`id`)))
JOIN `domain`ON
@@ -129,8 +133,10 @@ LEFT JOIN `vm_instance`ON
((`volumes`.`instance_id` = `vm_instance`.`id`)))
LEFT JOIN `user_vm`ON
((`user_vm`.`id` = `vm_instance`.`id`)))
-LEFT JOIN `volume_store_ref`ON
+LEFT JOIN `volume_store_ref` ON
((`volumes`.`id` = `volume_store_ref`.`volume_id`)))
+LEFT JOIN `kms_keys` ON
+ ((`volumes`.`kms_key_id` = `kms_keys`.`id`)))
LEFT JOIN `service_offering`ON
((`vm_instance`.`service_offering_id` = `service_offering`.`id`)))
LEFT JOIN `disk_offering`ON
diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java
index 43218b3f6a02..49fce4e70405 100644
--- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java
+++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java
@@ -29,6 +29,7 @@
import com.cloud.utils.db.TransactionCallbackNoReturn;
import com.cloud.utils.db.TransactionStatus;
import org.apache.cloudstack.engine.orchestration.service.VolumeOrchestrationService;
+import org.apache.cloudstack.framework.kms.KMSException;
import org.apache.cloudstack.secret.dao.PassphraseDao;
import org.apache.cloudstack.secret.PassphraseVO;
import com.cloud.service.dao.ServiceOfferingDetailsDao;
@@ -46,6 +47,8 @@
import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
import org.apache.cloudstack.engine.subsystem.api.storage.ObjectInDataStoreStateMachine;
import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo;
+import org.apache.cloudstack.kms.KMSManager;
+import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao;
import org.apache.cloudstack.storage.command.CopyCmdAnswer;
import org.apache.cloudstack.storage.command.CreateObjectAnswer;
import org.apache.cloudstack.storage.datastore.ObjectInDataStoreManager;
@@ -98,6 +101,10 @@ public class VolumeObject implements VolumeInfo {
@Inject
VolumeDataStoreDao volumeStoreDao;
@Inject
+ KMSManager kmsManager;
+ @Inject
+ KMSWrappedKeyDao kmsWrappedKeyDao;
+ @Inject
ObjectInDataStoreManager objectInStoreMgr;
@Inject
ResourceLimitService resourceLimitMgr;
@@ -900,6 +907,26 @@ public void setPassphraseId(Long id) {
volumeVO.setPassphraseId(id);
}
+ @Override
+ public Long getKmsKeyId() {
+ return volumeVO.getKmsKeyId();
+ }
+
+ @Override
+ public void setKmsKeyId(Long id) {
+ volumeVO.setKmsKeyId(id);
+ }
+
+ @Override
+ public Long getKmsWrappedKeyId() {
+ return volumeVO.getKmsWrappedKeyId();
+ }
+
+ @Override
+ public void setKmsWrappedKeyId(Long id) {
+ volumeVO.setKmsWrappedKeyId(id);
+ }
+
/**
* Removes passphrase reference from underlying volume. Also removes the associated passphrase entry if it is the last user.
*/
@@ -929,9 +956,29 @@ public void doInTransactionWithoutResult(TransactionStatus status) {
/**
* Looks up passphrase from underlying volume.
- * @return passphrase as bytes
+ * Supports both legacy passphrase-based encryption and KMS-based encryption.
+ * @return passphrase/DEK as base64-encoded bytes (UTF-8 bytes of base64 string)
*/
public byte[] getPassphrase() {
+ // First check for KMS-encrypted volume
+ if (volumeVO.getKmsWrappedKeyId() != null) {
+ try {
+ // Unwrap the DEK from KMS (returns raw binary bytes)
+ byte[] dekBytes = kmsManager.unwrapKey(volumeVO.getKmsWrappedKeyId());
+ // Base64-encode the DEK for consistency with legacy passphrases
+ // and for use with qemu-img which expects base64 format
+ String base64Dek = java.util.Base64.getEncoder().encodeToString(dekBytes);
+ // Zeroize the raw DEK bytes
+ java.util.Arrays.fill(dekBytes, (byte) 0);
+ // Return UTF-8 bytes of the base64 string
+ return base64Dek.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ } catch (KMSException e) {
+ logger.error("Failed to unwrap KMS key for volume {}: {}", volumeVO, e.getMessage(), e);
+ return new byte[0];
+ }
+ }
+
+ // Fallback to legacy passphrase-based encryption
PassphraseVO passphrase = passphraseDao.findById(volumeVO.getPassphraseId());
if (passphrase != null) {
return passphrase.getPassphrase();
diff --git a/framework/kms/pom.xml b/framework/kms/pom.xml
new file mode 100644
index 000000000000..719072ac493a
--- /dev/null
+++ b/framework/kms/pom.xml
@@ -0,0 +1,46 @@
+
+
+ 4.0.0
+ cloud-framework-kms
+ Apache CloudStack Framework - Key Management Service
+ Core KMS framework with provider-agnostic interfaces
+
+
+ org.apache.cloudstack
+ cloudstack-framework
+ 4.23.0.0-SNAPSHOT
+ ../pom.xml
+
+
+
+
+ org.apache.cloudstack
+ cloud-utils
+ ${project.version}
+
+
+ org.apache.cloudstack
+ cloud-framework-config
+ ${project.version}
+
+
+
diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java
new file mode 100644
index 000000000000..8f15ad24ac6e
--- /dev/null
+++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java
@@ -0,0 +1,181 @@
+// 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 com.cloud.utils.exception.CloudRuntimeException;
+
+/**
+ * Exception class for KMS-related errors with structured error types
+ * to enable proper retry logic and error handling.
+ */
+public class KMSException extends CloudRuntimeException {
+
+ /**
+ * Error types for KMS operations to enable intelligent retry logic
+ */
+ public enum ErrorType {
+ CONNECTION_FAILED(true),
+ /**
+ * Authentication failed (e.g., incorrect PIN)
+ */
+ AUTHENTICATION_FAILED(false),
+ /**
+ * Provider not initialized or unavailable
+ */
+ PROVIDER_NOT_INITIALIZED(false),
+
+ /**
+ * KEK not found in backend
+ */
+ KEK_NOT_FOUND(false),
+
+ /**
+ * KEK with given label already exists
+ */
+ KEY_ALREADY_EXISTS(false),
+
+ /**
+ * Invalid parameters provided
+ */
+ INVALID_PARAMETER(false),
+
+ /**
+ * Wrap/unwrap operation failed
+ */
+ WRAP_UNWRAP_FAILED(true),
+
+ /**
+ * KEK operation (create/delete) failed
+ */
+ KEK_OPERATION_FAILED(true),
+
+ /**
+ * Health check failed
+ */
+ HEALTH_CHECK_FAILED(true),
+
+ /**
+ * Transient network or communication error
+ */
+ TRANSIENT_ERROR(true),
+
+ /**
+ * Unknown error
+ */
+ UNKNOWN(false);
+
+ private final boolean retryable;
+
+ ErrorType(boolean retryable) {
+ this.retryable = retryable;
+ }
+
+ public boolean isRetryable() {
+ return retryable;
+ }
+ }
+
+ private final ErrorType errorType;
+
+ public KMSException(String message) {
+ super(message);
+ this.errorType = ErrorType.UNKNOWN;
+ }
+
+ public KMSException(String message, Throwable cause) {
+ super(message, cause);
+ this.errorType = ErrorType.UNKNOWN;
+ }
+
+ public KMSException(ErrorType errorType, String message) {
+ super(message);
+ this.errorType = errorType;
+ }
+
+ public KMSException(ErrorType errorType, String message, Throwable cause) {
+ super(message, cause);
+ this.errorType = errorType;
+ }
+
+ public static KMSException providerNotInitialized(String details) {
+ return new KMSException(ErrorType.PROVIDER_NOT_INITIALIZED,
+ "KMS provider not initialized: " + details);
+ }
+
+ public static KMSException kekNotFound(String kekId) {
+ return new KMSException(ErrorType.KEK_NOT_FOUND,
+ "KEK not found: " + kekId);
+ }
+
+ public static KMSException keyAlreadyExists(String details) {
+ return new KMSException(ErrorType.KEY_ALREADY_EXISTS,
+ "Key already exists: " + details);
+ }
+
+ public static KMSException invalidParameter(String details) {
+ return new KMSException(ErrorType.INVALID_PARAMETER,
+ "Invalid parameter: " + details);
+ }
+
+ public static KMSException wrapUnwrapFailed(String details, Throwable cause) {
+ return new KMSException(ErrorType.WRAP_UNWRAP_FAILED,
+ "Wrap/unwrap operation failed: " + details, cause);
+ }
+
+ public static KMSException wrapUnwrapFailed(String details) {
+ return new KMSException(ErrorType.WRAP_UNWRAP_FAILED,
+ "Wrap/unwrap operation failed: " + details);
+ }
+
+ public static KMSException kekOperationFailed(String details, Throwable cause) {
+ return new KMSException(ErrorType.KEK_OPERATION_FAILED,
+ "KEK operation failed: " + details, cause);
+ }
+
+ public static KMSException kekOperationFailed(String details) {
+ return new KMSException(ErrorType.KEK_OPERATION_FAILED,
+ "KEK operation failed: " + details);
+ }
+
+ public static KMSException healthCheckFailed(String details, Throwable cause) {
+ return new KMSException(ErrorType.HEALTH_CHECK_FAILED,
+ "Health check failed: " + details, cause);
+ }
+
+ public static KMSException transientError(String details, Throwable cause) {
+ return new KMSException(ErrorType.TRANSIENT_ERROR,
+ "Transient error: " + details, cause);
+ }
+
+ public ErrorType getErrorType() {
+ return errorType;
+ }
+
+ @Override
+ public String toString() {
+ return "KMSException{" +
+ "errorType=" + errorType +
+ ", retryable=" + isRetryable() +
+ ", message='" + getMessage() + '\'' +
+ '}';
+ }
+
+ public boolean isRetryable() {
+ return errorType.isRetryable();
+ }
+}
diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java
new file mode 100644
index 000000000000..388d464caa75
--- /dev/null
+++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java
@@ -0,0 +1,255 @@
+// 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 com.cloud.utils.component.Adapter;
+import org.apache.cloudstack.framework.config.Configurable;
+
+/**
+ * Abstract provider contract for Key Management Service operations.
+ *
+ * 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 details) throws KMSException {
+ // default no-op
+ }
+
+ /**
+ * Check if a KEK exists and is accessible
+ *
+ * @param kekId the KEK identifier to check
+ * @return true if KEK is available
+ * @throws KMSException if check fails
+ */
+ boolean isKekAvailable(String kekId) throws KMSException;
+
+ /**
+ * Wrap (encrypt) a plaintext Data Encryption Key with a KEK.
+ * Delegates to {@link #wrapKey(byte[], KeyPurpose, String, Long)} with null profile ID.
+ *
+ * @param plainDek the plaintext DEK to wrap (caller must zeroize after call)
+ * @param purpose the intended purpose of this DEK
+ * @param kekLabel the label of the KEK to use for wrapping
+ * @return WrappedKey containing the encrypted DEK and metadata
+ * @throws KMSException if wrapping fails or KEK not found
+ */
+ default WrappedKey wrapKey(byte[] plainDek, KeyPurpose purpose, String kekLabel) throws KMSException {
+ return wrapKey(plainDek, purpose, kekLabel, null);
+ }
+
+ /**
+ * Wrap (encrypt) a plaintext Data Encryption Key with a KEK using explicit HSM profile.
+ *
+ * @param plainDek the plaintext DEK to wrap (caller must zeroize after call)
+ * @param purpose the intended purpose of this DEK
+ * @param kekLabel the label of the KEK to use for wrapping
+ * @param hsmProfileId optional HSM profile ID to use (null for auto-resolution/default)
+ * @return WrappedKey containing the encrypted DEK and metadata
+ * @throws KMSException if wrapping fails or KEK not found
+ */
+ WrappedKey wrapKey(byte[] plainDek, KeyPurpose purpose, String kekLabel, Long hsmProfileId) throws KMSException;
+
+ /**
+ * Unwrap (decrypt) a wrapped DEK to obtain the plaintext key.
+ * Delegates to {@link #unwrapKey(WrappedKey, Long)} with null profile ID.
+ *
+ * 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..41f5cf461fcc
--- /dev/null
+++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java
@@ -0,0 +1,79 @@
+// 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 globally unique, collision-resistant KEK label with context
+ *
+ * @param domainId the domain ID associated with this key
+ * @param accountId the account ID associated with this key
+ * @param uuid the unique identifier of the key entity
+ * @param version the version number of the key
+ * @return formatted KEK label (e.g., "volume-kek-1-2-a8054d8f-...-1")
+ */
+ public String generateKekLabel(long domainId, long accountId, String uuid, int version) {
+ return name + "-kek-" + domainId + "-" + accountId + "-" + uuid.replace("-", "") + "-v" + version;
+ }
+}
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 @@
extensions
ipc
jobs
+ kms
managed-context
quota
rest
diff --git a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java
index 1ed75f14dfb1..429761d67772 100644
--- a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java
+++ b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java
@@ -188,7 +188,7 @@ protected List filterHostsByAffinityConstraints(List hosts, Affi
hosts = hosts.stream().filter(host -> !constraints.antiAffinityOccupiedHosts.contains(host.getId())).collect(Collectors.toList());
if (CollectionUtils.isEmpty(hosts)) {
String msg = String.format("Cannot find capacity for Kubernetes cluster: host anti-affinity requires each VM on a separate host, " +
- "but all %d available hosts in zone %s are already occupied by existing cluster VMs",
+ "but all %d available hosts in zone %s are already occupied by existing cluster VMs",
constraints.antiAffinityOccupiedHosts.size(), zone.getName());
throw new InsufficientServerCapacityException(msg, DataCenter.class, zone.getId());
}
@@ -198,8 +198,8 @@ protected List filterHostsByAffinityConstraints(List hosts, Affi
}
protected DeployDestination plan(final long nodesCount, final DataCenter zone, final ServiceOffering offering,
- final Long domainId, final Long accountId, final Hypervisor.HypervisorType hypervisorType,
- CPU.CPUArch arch, KubernetesClusterNodeType nodeType) throws InsufficientServerCapacityException {
+ final Long domainId, final Long accountId, final Hypervisor.HypervisorType hypervisorType,
+ CPU.CPUArch arch, KubernetesClusterNodeType nodeType) throws InsufficientServerCapacityException {
final int cpu_requested = offering.getCpu() * offering.getSpeed();
final long ram_requested = offering.getRamSize() * 1024L * 1024L;
boolean useDedicatedHosts = false;
@@ -295,7 +295,7 @@ protected DeployDestination plan(final long nodesCount, final DataCenter zone, f
String msg;
if (affinityConstraints.hasHostAntiAffinity) {
msg = String.format("Cannot find enough capacity for Kubernetes cluster (requested cpu=%d memory=%s) with offering: %s. " +
- "Host anti-affinity requires %d separate hosts but not enough suitable hosts are available in zone %s",
+ "Host anti-affinity requires %d separate hosts but not enough suitable hosts are available in zone %s",
cpu_requested * nodesCount, toHumanReadableSize(ram_requested * nodesCount), offering.getName(),
nodesCount, zone.getName());
} else {
@@ -399,7 +399,7 @@ protected void startKubernetesVM(final UserVm vm, final Long domainId, final Lon
}
protected List provisionKubernetesClusterNodeVms(final long nodeCount, final int offset,
- final String controlIpAddress, final Long domainId, final Long accountId) throws ManagementServerException,
+ final String controlIpAddress, final Long domainId, final Long accountId) throws ManagementServerException,
ResourceUnavailableException, InsufficientCapacityException {
List nodes = new ArrayList<>();
for (int i = offset + 1; i <= nodeCount; i++) {
@@ -468,12 +468,14 @@ protected UserVm createKubernetesNode(String joinIp, Long domainId, Long account
nodeVm = userVmService.createAdvancedSecurityGroupVirtualMachine(zone, serviceOffering, workerNodeTemplate, networkIds, securityGroupIds, owner,
hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST,base64UserData, null, null, keypairs,
null, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null,
- null, true, null, UserVmManager.CKS_NODE, null, null);
+ null, true, null, null, UserVmManager.CKS_NODE, null, null);
} else {
nodeVm = userVmService.createAdvancedVirtualMachine(zone, serviceOffering, workerNodeTemplate, networkIds, owner,
hostName, hostName, null, null, null, null,
Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST, base64UserData, null, null, keypairs,
- null, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, null, true, UserVmManager.CKS_NODE, null, null, null);
+ null, addrs, null, null, affinityGroupIds, customParameterMap,
+ null, null, null, null, true,
+ UserVmManager.CKS_NODE, null, null, null, null);
}
if (logger.isInfoEnabled()) {
logger.info("Created node VM : {}, {} in the Kubernetes cluster : {}", hostName, nodeVm, kubernetesCluster.getName());
@@ -504,7 +506,7 @@ protected void provisionFirewallRules(final IpAddress publicIp, final Account ac
}
protected void provisionPublicIpPortForwardingRule(IpAddress publicIp, Network network, Account account,
- final long vmId, final int sourcePort, final int destPort) throws NetworkRuleConflictException, ResourceUnavailableException {
+ final long vmId, final int sourcePort, final int destPort) throws NetworkRuleConflictException, ResourceUnavailableException {
final long publicIpId = publicIp.getId();
final long networkId = network.getId();
final long accountId = account.getId();
@@ -543,7 +545,7 @@ protected void provisionPublicIpPortForwardingRule(IpAddress publicIp, Network n
* @throws NetworkRuleConflictException
*/
protected void provisionSshPortForwardingRules(IpAddress publicIp, Network network, Account account,
- List clusterVMIds, Map vmIdPortMap) throws ResourceUnavailableException,
+ List clusterVMIds, Map vmIdPortMap) throws ResourceUnavailableException,
NetworkRuleConflictException {
if (!CollectionUtils.isEmpty(clusterVMIds)) {
int defaultNodesCount = clusterVMIds.size() - vmIdPortMap.size();
@@ -566,7 +568,7 @@ protected FirewallRule removeApiFirewallRule(final IpAddress publicIp) {
Integer startPort = firewallRule.getSourcePortStart();
Integer endPort = firewallRule.getSourcePortEnd();
if (startPort != null && startPort == CLUSTER_API_PORT &&
- endPort != null && endPort == CLUSTER_API_PORT) {
+ endPort != null && endPort == CLUSTER_API_PORT) {
rule = firewallRule;
firewallService.revokeIngressFwRule(firewallRule.getId(), true);
logger.debug("The API firewall rule [%s] with the id [%s] was revoked",firewallRule.getName(),firewallRule.getId());
@@ -613,7 +615,7 @@ protected void removePortForwardingRules(final IpAddress publicIp, final Network
}
protected void removePortForwardingRules(final IpAddress publicIp, final Network network, final Account account, int startPort, int endPort)
- throws ResourceUnavailableException {
+ throws ResourceUnavailableException {
List pfRules = portForwardingRulesDao.listByNetwork(network.getId());
for (PortForwardingRuleVO pfRule : pfRules) {
if (startPort <= pfRule.getSourcePortStart() && pfRule.getSourcePortStart() <= endPort) {
@@ -626,10 +628,10 @@ protected void removePortForwardingRules(final IpAddress publicIp, final Network
}
protected void removeLoadBalancingRule(final IpAddress publicIp, final Network network,
- final Account account) throws ResourceUnavailableException {
+ final Account account) throws ResourceUnavailableException {
List loadBalancerRules = loadBalancerDao.listByIpAddress(publicIp.getId());
loadBalancerRules.stream().filter(lbRules -> lbRules.getNetworkId() == network.getId() && lbRules.getAccountId() == account.getId() && lbRules.getSourcePortStart() == CLUSTER_API_PORT
- && lbRules.getSourcePortEnd() == CLUSTER_API_PORT).forEach(lbRule -> {
+ && lbRules.getSourcePortEnd() == CLUSTER_API_PORT).forEach(lbRule -> {
lbService.deleteLoadBalancerRule(lbRule.getId(), true);
logger.debug("The load balancing rule with the Id: {} was removed",lbRule.getId());
});
@@ -665,12 +667,12 @@ protected void removeVpcTierAllowPortACLRule(final Network network, int startPor
IllegalAccessException, ResourceUnavailableException {
List aclItems = networkACLItemDao.listByACL(network.getNetworkACLId());
aclItems = aclItems.stream().filter(networkACLItem -> (networkACLItem.getProtocol() != null &&
- networkACLItem.getProtocol().equals("TCP") &&
- networkACLItem.getSourcePortStart() != null &&
- networkACLItem.getSourcePortStart().equals(startPort) &&
- networkACLItem.getSourcePortEnd() != null &&
- networkACLItem.getSourcePortEnd().equals(endPort) &&
- networkACLItem.getAction().equals(NetworkACLItem.Action.Allow)))
+ networkACLItem.getProtocol().equals("TCP") &&
+ networkACLItem.getSourcePortStart() != null &&
+ networkACLItem.getSourcePortStart().equals(startPort) &&
+ networkACLItem.getSourcePortEnd() != null &&
+ networkACLItem.getSourcePortEnd().equals(endPort) &&
+ networkACLItem.getAction().equals(NetworkACLItem.Action.Allow)))
.collect(Collectors.toList());
for (NetworkACLItemVO aclItem : aclItems) {
@@ -679,7 +681,7 @@ protected void removeVpcTierAllowPortACLRule(final Network network, int startPor
}
protected void provisionLoadBalancerRule(final IpAddress publicIp, final Network network,
- final Account account, final List clusterVMIds, final int port) throws NetworkRuleConflictException,
+ final Account account, final List clusterVMIds, final int port) throws NetworkRuleConflictException,
InsufficientAddressCapacityException {
LoadBalancer lb = lbService.createPublicLoadBalancerRule(null, "api-lb", "LB rule for API access",
port, port, port, port,
@@ -879,11 +881,11 @@ protected String getEtcdNodeNameForCluster() {
}
protected KubernetesClusterVO updateKubernetesClusterEntry(final Long cores, final Long memory, final Long size,
- final Long serviceOfferingId, final Boolean autoscaleEnabled,
- final Long minSize, final Long maxSize,
- final KubernetesClusterNodeType nodeType,
- final boolean updateNodeOffering,
- final boolean updateClusterOffering) {
+ final Long serviceOfferingId, final Boolean autoscaleEnabled,
+ final Long minSize, final Long maxSize,
+ final KubernetesClusterNodeType nodeType,
+ final boolean updateNodeOffering,
+ final boolean updateClusterOffering) {
return Transaction.execute((TransactionCallback) status -> {
KubernetesClusterVO updatedCluster = kubernetesClusterDao.createForUpdate(kubernetesCluster.getId());
@@ -941,9 +943,9 @@ protected boolean autoscaleCluster(boolean enable, Long minSize, Long maxSize) {
try {
if (enable) {
String command = String.format("sudo /opt/bin/autoscale-kube-cluster -i %s -e -M %d -m %d",
- kubernetesCluster.getUuid(), maxSize, minSize);
+ kubernetesCluster.getUuid(), maxSize, minSize);
Pair result = SshHelper.sshExecute(publicIpAddress, sshPort, getControlNodeLoginUser(),
- pkFile, null, command, 10000, 10000, 60000);
+ pkFile, null, command, 10000, 10000, 60000);
// Maybe the file isn't present. Try and copy it
if (!result.first()) {
@@ -953,12 +955,12 @@ protected boolean autoscaleCluster(boolean enable, Long minSize, Long maxSize) {
if (!createCloudStackSecret(keys)) {
logTransitStateAndThrow(Level.ERROR, String.format("Failed to setup keys for Kubernetes cluster %s",
- kubernetesCluster.getName()), kubernetesCluster.getId(), KubernetesCluster.Event.OperationFailed);
+ kubernetesCluster.getName()), kubernetesCluster.getId(), KubernetesCluster.Event.OperationFailed);
}
// If at first you don't succeed ...
result = SshHelper.sshExecute(publicIpAddress, sshPort, getControlNodeLoginUser(),
- pkFile, null, command, 10000, 10000, 60000);
+ pkFile, null, command, 10000, 10000, 60000);
if (!result.first()) {
throw new CloudRuntimeException(result.second());
}
@@ -966,7 +968,7 @@ protected boolean autoscaleCluster(boolean enable, Long minSize, Long maxSize) {
updateKubernetesClusterEntry(true, minSize, maxSize);
} else {
Pair result = SshHelper.sshExecute(publicIpAddress, sshPort, getControlNodeLoginUser(),
- pkFile, null, String.format("sudo /opt/bin/autoscale-kube-cluster -d"),
+ pkFile, null, String.format("sudo /opt/bin/autoscale-kube-cluster -d"),
10000, 10000, 60000);
if (!result.first()) {
throw new CloudRuntimeException(result.second());
diff --git a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java
index 4ed5ff0167c2..308fc07223de 100644
--- a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java
+++ b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java
@@ -142,8 +142,8 @@ private boolean isKubernetesVersionSupportsHA() {
}
private Pair getKubernetesControlNodeConfig(final String controlNodeIp, final String serverIp,
- final List etcdIps, final String hostName, final boolean haSupported,
- final boolean ejectIso, final boolean externalCni, final boolean setupCsi) throws IOException {
+ final List etcdIps, final String hostName, final boolean haSupported,
+ final boolean ejectIso, final boolean externalCni, final boolean setupCsi) throws IOException {
String k8sControlNodeConfig = readK8sConfigFile("/conf/k8s-control-node.yml");
final String apiServerCert = "{{ k8s_control_node.apiserver.crt }}";
final String apiServerKey = "{{ k8s_control_node.apiserver.key }}";
@@ -273,19 +273,21 @@ private Pair createKubernetesControlNode(final Network network, S
List affinityGroupIds = getMergedAffinityGroupIds(CONTROL, domainId, accountId);
String userDataDetails = kubernetesCluster.getCniConfigDetails();
if (kubernetesCluster.getSecurityGroupId() != null &&
- networkModel.checkSecurityGroupSupportForNetwork(owner, zone, networkIds,
- List.of(kubernetesCluster.getSecurityGroupId()))) {
+ networkModel.checkSecurityGroupSupportForNetwork(owner, zone, networkIds,
+ List.of(kubernetesCluster.getSecurityGroupId()))) {
List securityGroupIds = new ArrayList<>();
securityGroupIds.add(kubernetesCluster.getSecurityGroupId());
controlVm = userVmService.createAdvancedSecurityGroupVirtualMachine(zone, serviceOffering, controlNodeTemplate, networkIds, securityGroupIds, owner,
- hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST,base64UserData, userDataId, userDataDetails, keypairs,
+ hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST,base64UserData, userDataId, userDataDetails, keypairs,
requestedIps, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null,
- null, true, null, UserVmManager.CKS_NODE, null, null);
+ null, true, null, null, UserVmManager.CKS_NODE, null, null);
} else {
controlVm = userVmService.createAdvancedVirtualMachine(zone, serviceOffering, controlNodeTemplate, networkIds, owner,
hostName, hostName, null, null, null, null,
Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST, base64UserData, userDataId, userDataDetails, keypairs,
- requestedIps, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, null, true, UserVmManager.CKS_NODE, null, null, null);
+ requestedIps, addrs, null, null, affinityGroupIds, customParameterMap,
+ null, null, null, null, true,
+ UserVmManager.CKS_NODE, null, null, null, null);
}
if (logger.isInfoEnabled()) {
logger.info("Created control VM: {}, {} in the Kubernetes cluster: {}", controlVm, hostName, kubernetesCluster);
@@ -372,7 +374,7 @@ private List getEtcdNodeHostnames() {
}
private String getEtcdNodeConfig(final List ipAddresses, final List hostnames, final int etcdNodeIndex,
- final boolean ejectIso) throws IOException {
+ final boolean ejectIso) throws IOException {
String k8sEtcdNodeConfig = readK8sConfigFile("/conf/etcd-node.yml");
final String sshPubKey = "{{ k8s.ssh.pub.key }}";
final String ejectIsoKey = "{{ k8s.eject.iso }}";
@@ -406,7 +408,7 @@ private String getEtcdNodeConfig(final List ipAddresses, final List affinityGroupIds = getMergedAffinityGroupIds(CONTROL, domainId, accountId);
if (kubernetesCluster.getSecurityGroupId() != null &&
- networkModel.checkSecurityGroupSupportForNetwork(owner, zone, networkIds,
- List.of(kubernetesCluster.getSecurityGroupId()))) {
+ networkModel.checkSecurityGroupSupportForNetwork(owner, zone, networkIds,
+ List.of(kubernetesCluster.getSecurityGroupId()))) {
List securityGroupIds = new ArrayList<>();
securityGroupIds.add(kubernetesCluster.getSecurityGroupId());
additionalControlVm = userVmService.createAdvancedSecurityGroupVirtualMachine(zone, serviceOffering, controlNodeTemplate, networkIds, securityGroupIds, owner,
hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST,base64UserData, null, null, keypairs,
null, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null,
- null, true, null, UserVmManager.CKS_NODE, null, null);
+ null, true, null, null, UserVmManager.CKS_NODE, null, null);
} else {
additionalControlVm = userVmService.createAdvancedVirtualMachine(zone, serviceOffering, controlNodeTemplate, networkIds, owner,
hostName, hostName, null, null, null, null,
Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST, base64UserData, null, null, keypairs,
- null, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, null, true, UserVmManager.CKS_NODE, null, null, null);
+ null, addrs, null, null, affinityGroupIds, customParameterMap,
+ null, null, null, null, true,
+ UserVmManager.CKS_NODE, null, null, null, null);
}
if (logger.isInfoEnabled()) {
@@ -488,12 +492,14 @@ private UserVm createEtcdNode(List requestedIps, List requestedIps, List provisionKubernetesClusterControlVm(final Network network, final String publicIpAddress, final List etcdIps,
- final Long domainId, final Long accountId, Long asNumber) throws
+ final Long domainId, final Long accountId, Long asNumber) throws
ManagementServerException, InsufficientCapacityException, ResourceUnavailableException {
UserVm k8sControlVM = null;
Pair k8sControlVMAndControlIP;
@@ -525,7 +531,7 @@ private Pair provisionKubernetesClusterControlVm(final Network n
}
private List provisionKubernetesClusterAdditionalControlVms(final String controlIpAddress, final Long domainId,
- final Long accountId) throws
+ final Long accountId) throws
InsufficientCapacityException, ManagementServerException, ResourceUnavailableException {
List additionalControlVms = new ArrayList<>();
if (kubernetesCluster.getControlNodeCount() > 1) {
@@ -762,7 +768,7 @@ public boolean startKubernetesClusterOnCreate(Long domainId, Long accountId, Lon
}
publicIpAddress = publicIpSshPort.first();
if (StringUtils.isEmpty(publicIpAddress) &&
- (!manager.isDirectAccess(network) || kubernetesCluster.getControlNodeCount() > 1)) { // Shared network, single-control node cluster won't have an IP yet
+ (!manager.isDirectAccess(network) || kubernetesCluster.getControlNodeCount() > 1)) { // Shared network, single-control node cluster won't have an IP yet
logTransitStateAndThrow(Level.ERROR, String.format("Failed to start Kubernetes cluster : %s as no public IP found for the cluster" , kubernetesCluster.getName()), kubernetesCluster.getId(), KubernetesCluster.Event.CreateFailed);
}
// Allow account creating the kubernetes cluster to access systemVM template
diff --git a/plugins/kms/database/pom.xml b/plugins/kms/database/pom.xml
new file mode 100644
index 000000000000..2bbeb2dc75b7
--- /dev/null
+++ b/plugins/kms/database/pom.xml
@@ -0,0 +1,73 @@
+
+
+
+ 4.0.0
+ cloud-plugin-kms-database
+ Apache CloudStack Plugin - KMS Database Provider
+ Database-backed KMS provider for encrypted key storage
+
+
+ org.apache.cloudstack
+ cloudstack-kms-plugins
+ 4.23.0.0-SNAPSHOT
+ ../pom.xml
+
+
+
+
+ org.apache.cloudstack
+ cloud-framework-kms
+ ${project.version}
+
+
+ org.apache.cloudstack
+ cloud-framework-config
+ ${project.version}
+
+
+ org.apache.cloudstack
+ cloud-utils
+ ${project.version}
+
+
+ com.google.crypto.tink
+ tink
+ ${cs.tink.version}
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+
+ true
+
+
+
+
+
+
+
diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java
new file mode 100644
index 000000000000..0d9ca1aa405d
--- /dev/null
+++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java
@@ -0,0 +1,390 @@
+// 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;
+
+import com.cloud.utils.component.AdapterBase;
+import com.cloud.utils.crypt.DBEncryptionUtil;
+import com.google.crypto.tink.subtle.AesGcmJce;
+
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.framework.kms.KMSException;
+import org.apache.cloudstack.framework.kms.KMSProvider;
+import org.apache.cloudstack.framework.kms.KeyPurpose;
+import org.apache.cloudstack.framework.kms.WrappedKey;
+import org.apache.cloudstack.kms.HSMProfileVO;
+import org.apache.cloudstack.kms.dao.HSMProfileDao;
+import org.apache.cloudstack.kms.provider.database.KMSDatabaseKekObjectVO;
+import org.apache.cloudstack.kms.provider.database.dao.KMSDatabaseKekObjectDao;
+import org.apache.commons.lang3.StringUtils;
+
+import com.cloud.utils.db.SearchBuilder;
+import com.cloud.utils.db.SearchCriteria;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import javax.inject.Inject;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * Database-backed KMS provider that stores master KEKs in a PKCS#11-like object table.
+ * Uses AES-256-GCM for all cryptographic operations.
+ *
+ * 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 static final String DEFAULT_PROFILE_NAME = "default";
+ private static final long SYSTEM_ACCOUNT_ID = 1L;
+ private static final long ROOT_DOMAIN_ID = 1L;
+
+ private final SecureRandom secureRandom = new SecureRandom();
+ @Inject
+ private KMSDatabaseKekObjectDao kekObjectDao;
+ @Inject
+ private HSMProfileDao hsmProfileDao;
+
+ @Override
+ public boolean start() {
+ super.start();
+ ensureDefaultHSMProfile();
+ return true;
+ }
+
+ @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)) {
+ throw KMSException.invalidParameter("KEK label cannot be empty");
+ }
+
+ 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());
+ }
+ }
+
+ /**
+ * Seeds the default database HSM profile if it does not already exist.
+ * This runs at provider startup to avoid FK constraint issues that occur
+ * when the INSERT is placed in the schema upgrade SQL script (the account
+ * table may not yet be populated when the upgrade script executes on a
+ * fresh install).
+ */
+ private void ensureDefaultHSMProfile() {
+ try {
+ SearchBuilder sb = hsmProfileDao.createSearchBuilder();
+ sb.and("name", sb.entity().getName(), SearchCriteria.Op.EQ);
+ sb.and("system", sb.entity().isSystem(), SearchCriteria.Op.EQ);
+ sb.and("protocol", sb.entity().getProtocol(), SearchCriteria.Op.EQ);
+ sb.done();
+
+ SearchCriteria sc = sb.create();
+ sc.setParameters("name", DEFAULT_PROFILE_NAME);
+ sc.setParameters("system", true);
+ sc.setParameters("protocol", PROVIDER_NAME);
+
+ List existing = hsmProfileDao.customSearchIncludingRemoved(sc, null);
+ if (existing != null && !existing.isEmpty()) {
+ logger.debug("Default database HSM profile already exists (id={})", existing.get(0).getId());
+ return;
+ }
+
+ HSMProfileVO profile = new HSMProfileVO(DEFAULT_PROFILE_NAME, PROVIDER_NAME,
+ SYSTEM_ACCOUNT_ID, ROOT_DOMAIN_ID, null, null);
+ profile.setEnabled(false);
+ profile.setSystem(true);
+ hsmProfileDao.persist(profile);
+ logger.info("Seeded default database HSM profile (id={}, uuid={})", profile.getId(), profile.getUuid());
+ } catch (Exception e) {
+ logger.warn("Failed to seed default database HSM profile: {}", e.getMessage(), e);
+ }
+ }
+
+
+ @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 {
+
+ /**
+ * Find a KEK object by label (PKCS#11 CKA_LABEL)
+ */
+ KMSDatabaseKekObjectVO findByLabel(String label);
+
+ /**
+ * Find a KEK object by object ID (PKCS#11 CKA_ID)
+ */
+ KMSDatabaseKekObjectVO findByObjectId(byte[] objectId);
+
+ /**
+ * List all KEK objects by purpose
+ */
+ List listByPurpose(KeyPurpose purpose);
+
+ /**
+ * List all KEK objects by key type (PKCS#11 CKA_KEY_TYPE)
+ */
+ List listByKeyType(String keyType);
+
+ /**
+ * List all KEK objects by object class (PKCS#11 CKA_CLASS)
+ */
+ List listByObjectClass(String objectClass);
+
+ /**
+ * Check if a KEK object exists with the given label
+ */
+ boolean existsByLabel(String label);
+}
diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDaoImpl.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDaoImpl.java
new file mode 100644
index 000000000000..ae65f3248b30
--- /dev/null
+++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDaoImpl.java
@@ -0,0 +1,84 @@
+// 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.GenericDaoBase;
+import com.cloud.utils.db.SearchBuilder;
+import com.cloud.utils.db.SearchCriteria;
+import org.apache.cloudstack.framework.kms.KeyPurpose;
+import org.apache.cloudstack.kms.provider.database.KMSDatabaseKekObjectVO;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+public class KMSDatabaseKekObjectDaoImpl extends GenericDaoBase implements KMSDatabaseKekObjectDao {
+
+ private final SearchBuilder allFieldSearch;
+
+ public KMSDatabaseKekObjectDaoImpl() {
+ allFieldSearch = createSearchBuilder();
+ allFieldSearch.and("uuid", allFieldSearch.entity().getUuid(), SearchCriteria.Op.EQ);
+ allFieldSearch.and("label", allFieldSearch.entity().getLabel(), SearchCriteria.Op.EQ);
+ allFieldSearch.and("objectId", allFieldSearch.entity().getObjectId(), SearchCriteria.Op.EQ);
+ allFieldSearch.and("purpose", allFieldSearch.entity().getPurpose(), SearchCriteria.Op.EQ);
+ allFieldSearch.and("keyType", allFieldSearch.entity().getKeyType(), SearchCriteria.Op.EQ);
+ allFieldSearch.and("objectClass", allFieldSearch.entity().getObjectClass(), SearchCriteria.Op.EQ);
+ allFieldSearch.done();
+ }
+
+ @Override
+ public KMSDatabaseKekObjectVO findByLabel(String label) {
+ SearchCriteria sc = allFieldSearch.create();
+ sc.setParameters("label", label);
+ return findOneBy(sc);
+ }
+
+ @Override
+ public KMSDatabaseKekObjectVO findByObjectId(byte[] objectId) {
+ SearchCriteria sc = allFieldSearch.create();
+ sc.setParameters("objectId", objectId);
+ return findOneBy(sc);
+ }
+
+ @Override
+ public List listByPurpose(KeyPurpose purpose) {
+ SearchCriteria sc = allFieldSearch.create();
+ sc.setParameters("purpose", purpose);
+ return listBy(sc);
+ }
+
+ @Override
+ public List listByKeyType(String keyType) {
+ SearchCriteria sc = allFieldSearch.create();
+ sc.setParameters("keyType", keyType);
+ return listBy(sc);
+ }
+
+ @Override
+ public List listByObjectClass(String objectClass) {
+ SearchCriteria sc = allFieldSearch.create();
+ sc.setParameters("objectClass", objectClass);
+ return listBy(sc);
+ }
+
+ @Override
+ public boolean existsByLabel(String label) {
+ return findByLabel(label) != null;
+ }
+}
diff --git a/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties
new file mode 100644
index 000000000000..8d43cd9e08b8
--- /dev/null
+++ b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties
@@ -0,0 +1,18 @@
+# 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=database-kms
+parent=kms
diff --git a/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml
new file mode 100644
index 000000000000..186e8adfa714
--- /dev/null
+++ b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
diff --git a/plugins/kms/pkcs11/pom.xml b/plugins/kms/pkcs11/pom.xml
new file mode 100644
index 000000000000..1aaa88415769
--- /dev/null
+++ b/plugins/kms/pkcs11/pom.xml
@@ -0,0 +1,73 @@
+
+
+
+ 4.0.0
+ cloud-plugin-kms-pkcs11
+ Apache CloudStack Plugin - KMS PKCS#11 Provider
+ PKCS#11-backed KMS provider for HSM integration
+
+
+ org.apache.cloudstack
+ cloudstack-kms-plugins
+ 4.23.0.0-SNAPSHOT
+ ../pom.xml
+
+
+
+
+ org.apache.cloudstack
+ cloud-framework-kms
+ ${project.version}
+
+
+ org.apache.cloudstack
+ cloud-framework-config
+ ${project.version}
+
+
+ org.apache.cloudstack
+ cloud-utils
+ ${project.version}
+
+
+ org.apache.cloudstack
+ cloud-engine-schema
+ ${project.version}
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+
+
+
+ true
+
+
+
+
+
+
+
diff --git a/plugins/kms/pkcs11/src/main/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProvider.java b/plugins/kms/pkcs11/src/main/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProvider.java
new file mode 100644
index 000000000000..3b3853d53a7f
--- /dev/null
+++ b/plugins/kms/pkcs11/src/main/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProvider.java
@@ -0,0 +1,1076 @@
+// 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.pkcs11;
+
+import com.cloud.utils.component.AdapterBase;
+import com.cloud.utils.crypt.DBEncryptionUtil;
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.framework.kms.KMSException;
+import org.apache.cloudstack.framework.kms.KMSProvider;
+import org.apache.cloudstack.framework.kms.KeyPurpose;
+import org.apache.cloudstack.framework.kms.WrappedKey;
+import org.apache.cloudstack.kms.HSMProfileDetailsVO;
+import org.apache.cloudstack.kms.KMSKekVersionVO;
+import org.apache.cloudstack.kms.dao.HSMProfileDetailsDao;
+import org.apache.cloudstack.kms.dao.KMSKekVersionDao;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import javax.annotation.PostConstruct;
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.KeyGenerator;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+import javax.inject.Inject;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.Key;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.Provider;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.security.UnrecoverableKeyException;
+import java.security.cert.CertificateException;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
+ private static final Logger logger = LogManager.getLogger(PKCS11HSMProvider.class);
+ private static final String PROVIDER_NAME = "pkcs11";
+ // Security note: AES-CBC provides confidentiality but not authenticity (no
+ // HMAC).
+ // While AES-GCM is preferred, SunPKCS11 support for GCM is often buggy or
+ // missing
+ // depending on the underlying driver. We rely on the HSM/storage for tamper
+ // resistance.
+ // AES-CBC with PKCS5Padding: FIPS-compliant (NIST SP 800-38A) with universal PKCS#11 support
+ private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
+
+ private static final long SESSION_ACQUIRE_TIMEOUT_MS = 5000L;
+
+ private static final int[] VALID_KEY_SIZES = {128, 192, 256};
+ private final Map sessionPools = new ConcurrentHashMap<>();
+ @Inject
+ private HSMProfileDetailsDao hsmProfileDetailsDao;
+ @Inject
+ private KMSKekVersionDao kmsKekVersionDao;
+
+ @PostConstruct
+ public void init() {
+ logger.info("Initializing PKCS11HSMProvider");
+ }
+
+ @Override
+ public String getProviderName() {
+ return PROVIDER_NAME;
+ }
+
+ @Override
+ public String createKek(KeyPurpose purpose, String label, int keyBits, Long hsmProfileId) throws KMSException {
+ if (hsmProfileId == null) {
+ throw KMSException.invalidParameter("HSM Profile ID is required for PKCS#11 provider");
+ }
+ if (StringUtils.isEmpty(label)) {
+ throw KMSException.invalidParameter("KEK label cannot be empty");
+ }
+ return executeWithSession(hsmProfileId, session -> session.generateKey(label, keyBits, purpose));
+ }
+
+ @Override
+ public void deleteKek(String kekId) throws KMSException {
+ Long hsmProfileId = resolveProfileId(kekId);
+ executeWithSession(hsmProfileId, session -> {
+ session.deleteKey(kekId);
+ return null;
+ });
+ }
+
+ 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);
+ }
+
+ /**
+ * Validates HSM profile configuration for PKCS#11 provider.
+ *
+ *
+ * Validates:
+ *
+ * - {@code library}: Required, should point to PKCS#11 library
+ * - {@code slot}, {@code slot_list_index}, or {@code token_label}: At least
+ * one required
+ * - {@code pin}: Required for HSM authentication
+ * - {@code max_sessions}: Optional, must be positive integer if provided
+ *
+ *
+ * @param config Configuration map from HSM profile details
+ * @throws KMSException with {@code INVALID_PARAMETER} if validation fails
+ */
+ @Override
+ public void validateProfileConfig(Map config) throws KMSException {
+ String libraryPath = config.get("library");
+ if (StringUtils.isBlank(libraryPath)) {
+ throw KMSException.invalidParameter("library is required for PKCS#11 HSM profile");
+ }
+
+ String slot = config.get("slot");
+ String slotListIndex = config.get("slot_list_index");
+ String tokenLabel = config.get("token_label");
+ if (StringUtils.isAllBlank(slot, slotListIndex, tokenLabel)) {
+ throw KMSException.invalidParameter(
+ "One of 'slot', 'slot_list_index', or 'token_label' is required for PKCS#11 HSM profile");
+ }
+
+ if (StringUtils.isNotBlank(slot)) {
+ try {
+ Integer.parseInt(slot);
+ } catch (NumberFormatException e) {
+ throw KMSException.invalidParameter("slot must be a valid integer: " + slot);
+ }
+ }
+
+ if (StringUtils.isNotBlank(slotListIndex)) {
+ try {
+ int idx = Integer.parseInt(slotListIndex);
+ if (idx < 0) {
+ throw KMSException.invalidParameter("slot_list_index must be a non-negative integer");
+ }
+ } catch (NumberFormatException e) {
+ throw KMSException.invalidParameter("slot_list_index must be a valid integer: " + slotListIndex);
+ }
+ }
+
+ File libraryFile = new File(libraryPath);
+ if (!libraryFile.exists() && !libraryFile.isAbsolute()) {
+ // The HSM library might be in the system library path
+ logger.debug("Library path {} does not exist as absolute path, will rely on system library path",
+ libraryPath);
+ }
+
+ String max_sessions = config.get("max_sessions");
+ if (StringUtils.isNotBlank(max_sessions)) {
+ try {
+ int idx = Integer.parseInt(max_sessions);
+ if (idx <= 0) {
+ throw KMSException.invalidParameter("max_sessions must be greater than 0");
+ }
+ } catch (NumberFormatException e) {
+ throw KMSException.invalidParameter("max_sessions must be a valid integer: " + max_sessions);
+ }
+ }
+ }
+
+ @Override
+ public boolean isKekAvailable(String kekId) throws KMSException {
+ try {
+ Long hsmProfileId = resolveProfileId(kekId);
+ return executeWithSession(hsmProfileId, session -> session.checkKeyExists(kekId));
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ @Override
+ public WrappedKey wrapKey(byte[] plainDek, KeyPurpose purpose, String kekLabel,
+ Long hsmProfileId) throws KMSException {
+ if (hsmProfileId == null) {
+ hsmProfileId = resolveProfileId(kekLabel);
+ }
+
+ byte[] wrappedBlob = executeWithSession(hsmProfileId, session -> session.wrapKey(plainDek, kekLabel));
+ return new WrappedKey(kekLabel, purpose, CIPHER_ALGORITHM, wrappedBlob, PROVIDER_NAME, new Date(), null);
+ }
+
+ @Override
+ public byte[] unwrapKey(WrappedKey wrappedKey, Long hsmProfileId) throws KMSException {
+ if (hsmProfileId == null) {
+ hsmProfileId = resolveProfileId(wrappedKey.getKekId());
+ }
+
+ return executeWithSession(hsmProfileId,
+ session -> session.unwrapKey(wrappedKey.getWrappedKeyMaterial(), wrappedKey.getKekId()));
+ }
+
+ @Override
+ public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits,
+ Long hsmProfileId) throws KMSException {
+ byte[] dekBytes = new byte[keyBits / 8];
+ new SecureRandom().nextBytes(dekBytes);
+
+ try {
+ return wrapKey(dekBytes, purpose, kekLabel, hsmProfileId);
+ } finally {
+ Arrays.fill(dekBytes, (byte) 0);
+ }
+ }
+
+ @Override
+ public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel,
+ Long targetHsmProfileId) throws KMSException {
+ byte[] plainKey = unwrapKey(oldWrappedKey, null);
+ try {
+ Long profileId = targetHsmProfileId != null ? targetHsmProfileId : resolveProfileId(newKekLabel);
+ return wrapKey(plainKey, oldWrappedKey.getPurpose(), newKekLabel, profileId);
+ } finally {
+ Arrays.fill(plainKey, (byte) 0);
+ }
+ }
+
+ /**
+ * Performs health check on all configured HSM profiles.
+ *
+ * For each configured HSM profile:
+ *
+ * - Attempts to acquire a test session
+ * - Verifies HSM is responsive (lightweight KeyStore operation)
+ * - Releases the session
+ *
+ *
+ * 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);
+ }
+
+ /**
+ * 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 T executeWithSession(Long hsmProfileId, SessionOperation operation) throws KMSException {
+ HSMSessionPool pool = getSessionPool(hsmProfileId);
+ PKCS11Session session = null;
+ try {
+ session = pool.acquireSession(SESSION_ACQUIRE_TIMEOUT_MS);
+ return operation.execute(session);
+ } finally {
+ pool.releaseSession(session);
+ }
+ }
+
+ HSMSessionPool getSessionPool(Long profileId) {
+ return sessionPools.computeIfAbsent(profileId, id -> {
+ Map config = loadProfileConfig(id);
+ int maxSessions = Integer.parseInt(config.getOrDefault("max_sessions", "10"));
+ return new HSMSessionPool(id, maxSessions, this);
+ });
+ }
+
+ Map loadProfileConfig(Long profileId) {
+ List details = hsmProfileDetailsDao.listByProfileId(profileId);
+ Map config = new HashMap<>();
+ for (HSMProfileDetailsVO detail : details) {
+ String value = detail.getValue();
+ if (isSensitiveKey(detail.getName())) {
+ value = DBEncryptionUtil.decrypt(value);
+ }
+ config.put(detail.getName(), value);
+ }
+ validateProfileConfig(config);
+ return config;
+ }
+
+ boolean isSensitiveKey(String key) {
+ return KMSProvider.isSensitiveKey(key);
+ }
+
+
+ @Override
+ public String getConfigComponentName() {
+ return PKCS11HSMProvider.class.getSimpleName();
+ }
+
+ @Override
+ public ConfigKey>[] getConfigKeys() {
+ return new ConfigKey>[0];
+ }
+
+ @FunctionalInterface
+ private interface SessionOperation {
+ T execute(PKCS11Session session) throws KMSException;
+ }
+
+ private static class HSMSessionPool {
+ private final BlockingQueue availableSessions;
+ private final Long profileId;
+ private final PKCS11HSMProvider provider;
+ private final int maxSessions;
+ // Counts total sessions (idle + active). Acquired on creation, released on close.
+ private final Semaphore sessionPermits;
+ private volatile boolean invalidated = false;
+
+ HSMSessionPool(Long profileId, int maxSessions, PKCS11HSMProvider provider) {
+ this.profileId = profileId;
+ this.provider = provider;
+ this.maxSessions = maxSessions;
+ this.sessionPermits = new Semaphore(maxSessions);
+ this.availableSessions = new ArrayBlockingQueue<>(maxSessions);
+ }
+
+ PKCS11Session acquireSession(long timeoutMs) throws KMSException {
+ // Try to get an existing idle session first (no semaphore change: it already owns a permit).
+ PKCS11Session session = availableSessions.poll();
+ if (session != null) {
+ if (session.isValid()) {
+ return session;
+ }
+ // Stale idle session: discard it and free its permit so a new one can be created.
+ session.close();
+ sessionPermits.release();
+ }
+
+ // Acquire a permit to create a new session, blocking up to timeoutMs if at capacity.
+ try {
+ if (!sessionPermits.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) {
+ // One last try: a session may have been returned while we were waiting.
+ session = availableSessions.poll();
+ if (session != null && session.isValid()) {
+ return session;
+ }
+ if (session != null) {
+ session.close();
+ sessionPermits.release();
+ }
+ throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED,
+ "Timed out waiting for an available HSM session for profile " + profileId
+ + " (max=" + maxSessions + ", timeout=" + timeoutMs + "ms)");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED,
+ "Interrupted while waiting to acquire HSM session for profile " + profileId, e);
+ }
+
+ try {
+ return createNewSession();
+ } catch (KMSException e) {
+ sessionPermits.release();
+ throw e;
+ }
+ }
+
+ private PKCS11Session createNewSession() throws KMSException {
+ // Config (including decrypted PIN) is loaded fresh each time and not stored.
+ return new PKCS11Session(provider.loadProfileConfig(profileId));
+ }
+
+ void releaseSession(PKCS11Session session) {
+ if (session == null) return;
+ if (!invalidated && session.isValid() && availableSessions.offer(session)) {
+ return; // session returned to the idle pool; permit stays consumed
+ }
+ // Pool is invalidated, session is stale, or the idle queue is full: close immediately.
+ session.close();
+ sessionPermits.release();
+ }
+
+ /**
+ * Marks the pool as invalidated and closes all idle sessions.
+ * Any session currently checked out will be closed (and its permit released) when
+ * it is returned via {@link #releaseSession} — the invalidated flag prevents re-pooling.
+ */
+ void invalidate() {
+ invalidated = true;
+ PKCS11Session session;
+ while ((session = availableSessions.poll()) != null) {
+ session.close();
+ sessionPermits.release();
+ }
+ }
+ }
+
+ /**
+ * Inner class representing an active PKCS#11 session with an HSM.
+ * This class manages the connection to the HSM, key operations, and session lifecycle.
+ *
+ * Key operations supported:
+ *
+ * - Key generation: Generate AES keys directly in the HSM
+ * - Key wrapping: Encrypt DEKs using KEKs stored in the HSM (AES-CBC/PKCS5Padding)
+ * - Key unwrapping: Decrypt DEKs using KEKs stored in the HSM (AES-CBC/PKCS5Padding)
+ * - Key deletion: Remove keys from the HSM
+ * - Key existence check: Verify if a key exists in the HSM
+ *
+ *
+ * Configuration requirements:
+ *
+ * - {@code library}: Path to PKCS#11 library (required)
+ * - {@code slot} or {@code token_label}: HSM slot/token selection (at least one required)
+ * - {@code pin}: PIN for HSM authentication (required, sensitive)
+ *
+ *
+ * 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 config) throws KMSException {
+ connect(config);
+ }
+
+ /**
+ * Establishes connection to the PKCS#11 HSM.
+ *
+ * This method:
+ *
+ * - Validates required configuration (library, slot/token_label, pin)
+ * - Creates a SunPKCS11 provider with the HSM library
+ * - Loads the PKCS#11 KeyStore
+ * - Authenticates using the provided PIN
+ *
+ *
+ * Slot/token selection:
+ *
+ * - If {@code token_label} is provided, it is used (more reliable)
+ * - Otherwise, {@code slot} (numeric ID) is used
+ *
+ *
+ * @throws KMSException with appropriate ErrorType:
+ *
+ * - {@code AUTHENTICATION_FAILED} if PIN is incorrect
+ * - {@code INVALID_PARAMETER} if configuration is missing or invalid
+ * - {@code CONNECTION_FAILED} if HSM is unreachable or device error occurs
+ *
+ */
+ private void connect(Map config) throws KMSException {
+ try {
+ // Unique suffix ensures each session gets its own provider name in java.security.Security,
+ // allowing Security.removeProvider() in close() to target exactly this session's provider.
+ String nameSuffix = UUID.randomUUID().toString().substring(0, 8);
+
+ String configString = buildSunPKCS11Config(config, nameSuffix);
+
+ // Java 9+ API: write config to temp file, then configure the provider
+ tempConfigFile = Files.createTempFile("pkcs11-config-", ".cfg");
+ try (FileWriter writer = new FileWriter(tempConfigFile.toFile(), StandardCharsets.UTF_8)) {
+ writer.write(configString);
+ }
+
+ Provider baseProvider = Security.getProvider("SunPKCS11");
+ if (baseProvider == null) {
+ throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED,
+ "SunPKCS11 provider not available in this JVM");
+ }
+
+ provider = baseProvider.configure(tempConfigFile.toAbsolutePath().toString());
+
+ // Use the actual provider name so Security.removeProvider() in close() works correctly.
+ providerName = provider.getName();
+
+ // Security.addProvider returns -1 if a provider with this name is already registered.
+ // With the UUID-based suffix this should be impossible in practice; guard defensively.
+ if (Security.addProvider(provider) < 0) {
+ throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED,
+ "Failed to register PKCS#11 provider '" + providerName + "': name already in use");
+ }
+
+ keyStore = KeyStore.getInstance("PKCS11", provider);
+
+ String pin = config.get("pin");
+ if (StringUtils.isEmpty(pin)) {
+ throw KMSException.invalidParameter("pin is required");
+ }
+ char[] pinChars = pin.toCharArray();
+ keyStore.load(null, pinChars);
+ Arrays.fill(pinChars, '\0');
+
+ // The temp file is only needed during configure()/load(); delete it immediately
+ // rather than holding it until the session is eventually closed.
+ Files.deleteIfExists(tempConfigFile);
+ tempConfigFile = null;
+
+ logger.debug("Successfully connected to PKCS#11 HSM at {}", config.get("library"));
+ } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException e) {
+ handlePKCS11Exception(e, "Failed to initialize PKCS#11 connection");
+ } catch (IOException e) {
+ String errorMsg = e.getMessage();
+ if (errorMsg != null && errorMsg.contains("CKR_PIN_INCORRECT")) {
+ throw new KMSException(KMSException.ErrorType.AUTHENTICATION_FAILED,
+ "Incorrect PIN for HSM authentication", e);
+ } else if (errorMsg != null && errorMsg.contains("CKR_SLOT_ID_INVALID")) {
+ throw KMSException.invalidParameter("Invalid slot ID: " + config.get("slot"));
+ } else {
+ handlePKCS11Exception(e, "I/O error during PKCS#11 connection");
+ }
+ } catch (Exception e) {
+ handlePKCS11Exception(e, "Unexpected error during PKCS#11 connection");
+ }
+ }
+
+ /**
+ * Builds SunPKCS11 provider configuration string.
+ *
+ * @param config HSM profile configuration
+ * @return Configuration string for SunPKCS11 provider
+ * @throws KMSException if required configuration is missing
+ */
+ private String buildSunPKCS11Config(Map config, String nameSuffix) throws KMSException {
+ String libraryPath = config.get("library");
+ if (StringUtils.isBlank(libraryPath)) {
+ throw KMSException.invalidParameter("library is required");
+ }
+
+ StringBuilder configBuilder = new StringBuilder();
+ // Include the unique suffix so that each session is registered under a distinct
+ // provider name (SunPKCS11-CloudStackHSM-{suffix}), preventing name collisions
+ // across concurrent sessions and allowing clean removal via Security.removeProvider().
+ configBuilder.append("name=CloudStackHSM-").append(nameSuffix).append("\n");
+ configBuilder.append("library=").append(libraryPath).append("\n");
+
+ String tokenLabel = config.get("token_label");
+ String slotListIndex = config.get("slot_list_index");
+ String slot = config.get("slot");
+
+ if (StringUtils.isNotBlank(tokenLabel)) {
+ configBuilder.append("tokenLabel=").append(tokenLabel).append("\n");
+ } else if (StringUtils.isNotBlank(slotListIndex)) {
+ configBuilder.append("slotListIndex=").append(slotListIndex).append("\n");
+ } else if (StringUtils.isNotBlank(slot)) {
+ configBuilder.append("slot=").append(slot).append("\n");
+ } else {
+ throw KMSException.invalidParameter("One of 'slot', 'slot_list_index', or 'token_label' is required");
+ }
+
+ // Explicitly configure SunPKCS11 to generate AES keys as Data Encryption Keys.
+ // Strict HSMs (like Thales Luna in FIPS mode) forbid a key from having both
+ // CKA_WRAP and CKA_ENCRYPT attributes. Because CloudStack uses Cipher.ENCRYPT_MODE
+ // (which maps to C_Encrypt) to protect the DEK, the KEK must have CKA_ENCRYPT=true.
+ configBuilder.append("\nattributes(generate, CKO_SECRET_KEY, CKK_AES) = {\n");
+ configBuilder.append(" CKA_ENCRYPT = true\n");
+ configBuilder.append(" CKA_DECRYPT = true\n");
+ configBuilder.append(" CKA_WRAP = false\n");
+ configBuilder.append(" CKA_UNWRAP = false\n");
+ configBuilder.append("}\n");
+
+ return configBuilder.toString();
+ }
+
+ /**
+ * Maps PKCS#11 specific exceptions to appropriate KMSException.ErrorType.
+ *
+ * PKCS#11 error codes are parsed from exception messages and mapped as follows:
+ *
+ * - {@code CKR_PIN_INCORRECT} → {@code AUTHENTICATION_FAILED}
+ * - {@code CKR_SLOT_ID_INVALID} → {@code INVALID_PARAMETER}
+ * - {@code CKR_KEY_NOT_FOUND} → {@code KEK_NOT_FOUND}
+ * - {@code CKR_DEVICE_ERROR} → {@code CONNECTION_FAILED}
+ * - {@code CKR_SESSION_HANDLE_INVALID} → {@code CONNECTION_FAILED}
+ * - {@code CKR_KEY_ALREADY_EXISTS} → {@code KEY_ALREADY_EXISTS}
+ * - {@code KeyStoreException} → {@code WRAP_UNWRAP_FAILED}
+ * - Other errors → {@code KEK_OPERATION_FAILED}
+ *
+ *
+ * @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.
+ *
+ * Checks performed:
+ *
+ * - KeyStore object is not null
+ * - Provider is still registered in Security
+ * - HSM is responsive (lightweight operation: get KeyStore size)
+ *
+ *
+ * @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.
+ *
+ *
+ * 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:
+ *
+ * - Resides permanently in the HSM token storage
+ * - Is marked as non-extractable (CKA_EXTRACTABLE=false) by the HSM
+ * - Can only be used for cryptographic operations via the HSM
+ *
+ *
+ * @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) {
+ if (e.getMessage() != null
+ && e.getMessage().contains("found multiple secret keys sharing same CKA_LABEL")) {
+ logger.warn("Multiple duplicate keys found with label '{}' in HSM. Reusing the existing key. " +
+ "Please purge duplicate keys manually if possible.", label);
+ return label;
+ }
+ 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.
+ *
+ * Uses AES-CBC with PKCS5Padding (FIPS 197 + NIST SP 800-38A):
+ *
+ * - Generates a random 128-bit IV
+ * - Encrypts the DEK using AES-CBC with the KEK from HSM
+ * - Returns format: [IV (16 bytes)][ciphertext]
+ *
+ *
+ * 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:
+ *
+ * - {@code INVALID_PARAMETER} if plainDek is null or empty
+ * - {@code KEK_NOT_FOUND} if KEK with label doesn't exist or is not accessible
+ * - {@code WRAP_UNWRAP_FAILED} if wrapping operation fails
+ *
+ */
+ 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.
+ *
+ *
+ * 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:
+ *
+ * - {@code INVALID_PARAMETER} if wrappedBlob is null,
+ * empty, or too short
+ * - {@code KEK_NOT_FOUND} if KEK with label doesn't
+ * exist or is not accessible
+ * - {@code WRAP_UNWRAP_FAILED} if unwrapping fails
+ *
+ */
+ 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.
+ *
+ * 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:
+ *
+ * - {@code KEK_NOT_FOUND} if key with label doesn't exist
+ * - {@code KEK_OPERATION_FAILED} if deletion fails (e.g., key is in use)
+ *
+ */
+ 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 @@
+
+
+
+
+
+
+
+
+
diff --git a/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java b/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java
new file mode 100644
index 000000000000..a22c7651bd5d
--- /dev/null
+++ b/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java
@@ -0,0 +1,264 @@
+// 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.pkcs11;
+
+import org.apache.cloudstack.framework.kms.KMSException;
+import org.apache.cloudstack.framework.kms.KeyPurpose;
+import org.apache.cloudstack.kms.HSMProfileDetailsVO;
+import org.apache.cloudstack.kms.KMSKekVersionVO;
+import org.apache.cloudstack.kms.dao.HSMProfileDetailsDao;
+import org.apache.cloudstack.kms.dao.KMSKekVersionDao;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.Arrays;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for PKCS11HSMProvider
+ * Tests provider-specific logic: config loading, profile resolution, sensitive key detection
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class PKCS11HSMProviderTest {
+
+ @Spy
+ @InjectMocks
+ private PKCS11HSMProvider provider;
+
+ @Mock
+ private HSMProfileDetailsDao hsmProfileDetailsDao;
+
+ @Mock
+ private KMSKekVersionDao kmsKekVersionDao;
+
+ private Long testProfileId = 1L;
+ private String testKekLabel = "test-kek-label";
+
+ @Before
+ public void setUp() {
+ // Minimal setup
+ }
+
+ /**
+ * Test: resolveProfileId successfully finds profile from KEK label
+ */
+ @Test
+ public void testResolveProfileId_FindsFromKekLabel() throws KMSException {
+ // Setup: KEK version with profile ID
+ KMSKekVersionVO kekVersion = mock(KMSKekVersionVO.class);
+ when(kekVersion.getHsmProfileId()).thenReturn(testProfileId);
+ when(kmsKekVersionDao.findByKekLabel(testKekLabel)).thenReturn(kekVersion);
+
+ // Test
+ Long result = provider.resolveProfileId(testKekLabel);
+
+ // Verify
+ assertNotNull("Should return profile ID", result);
+ assertEquals("Should return correct profile ID", testProfileId, result);
+ verify(kmsKekVersionDao).findByKekLabel(testKekLabel);
+ }
+
+ /**
+ * Test: resolveProfileId throws exception when KEK version not found
+ */
+ @Test(expected = KMSException.class)
+ public void testResolveProfileId_ThrowsExceptionWhenVersionNotFound() throws KMSException {
+ // Setup: No KEK version found
+ when(kmsKekVersionDao.findByKekLabel(testKekLabel)).thenReturn(null);
+
+ // Test - should throw exception
+ provider.resolveProfileId(testKekLabel);
+ }
+
+ /**
+ * Test: resolveProfileId throws exception when profile ID is null
+ */
+ @Test(expected = KMSException.class)
+ public void testResolveProfileId_ThrowsExceptionWhenProfileIdNull() throws KMSException {
+ // Setup: KEK version exists but has null profile ID
+ KMSKekVersionVO kekVersion = mock(KMSKekVersionVO.class);
+ when(kekVersion.getHsmProfileId()).thenReturn(null);
+ when(kmsKekVersionDao.findByKekLabel(testKekLabel)).thenReturn(kekVersion);
+
+ // Test - should throw exception
+ provider.resolveProfileId(testKekLabel);
+ }
+
+ /**
+ * Test: loadProfileConfig loads and decrypts sensitive values
+ */
+ @Test
+ public void testLoadProfileConfig_DecryptsSensitiveValues() {
+ // Setup: Profile details with encrypted pin
+ HSMProfileDetailsVO detail1 = mock(HSMProfileDetailsVO.class);
+ when(detail1.getName()).thenReturn("library");
+ when(detail1.getValue()).thenReturn("/path/to/lib.so");
+
+ HSMProfileDetailsVO detail2 = mock(HSMProfileDetailsVO.class);
+ when(detail2.getName()).thenReturn("pin");
+ when(detail2.getValue()).thenReturn("ENC(encrypted_pin)");
+
+ HSMProfileDetailsVO detail3 = mock(HSMProfileDetailsVO.class);
+ when(detail3.getName()).thenReturn("slot");
+ when(detail3.getValue()).thenReturn("0");
+
+ when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(
+ Arrays.asList(detail1, detail2, detail3));
+
+ // Test
+ Map config = provider.loadProfileConfig(testProfileId);
+
+ // Verify
+ assertNotNull("Config should not be null", config);
+ assertEquals(3, config.size());
+ assertEquals("/path/to/lib.so", config.get("library"));
+ // Note: In real code, DBEncryptionUtil.decrypt would be called
+ // Here we just verify the structure is correct
+ assertTrue("Config should contain pin", config.containsKey("pin"));
+ assertEquals("0", config.get("slot"));
+
+ verify(hsmProfileDetailsDao).listByProfileId(testProfileId);
+ }
+
+ /**
+ * Test: loadProfileConfig handles empty details
+ */
+ @Test(expected = KMSException.class)
+ public void testLoadProfileConfig_HandlesEmptyDetails() {
+ // Setup
+ when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(Arrays.asList());
+
+ // Test
+ Map config = provider.loadProfileConfig(testProfileId);
+ }
+
+ /**
+ * Test: isSensitiveKey correctly identifies sensitive keys
+ */
+ @Test
+ public void testIsSensitiveKey_IdentifiesSensitiveKeys() {
+ // Test
+ assertTrue(provider.isSensitiveKey("pin"));
+ assertTrue(provider.isSensitiveKey("password"));
+ assertTrue(provider.isSensitiveKey("api_secret"));
+ assertTrue(provider.isSensitiveKey("private_key"));
+ assertTrue(provider.isSensitiveKey("PIN")); // Case-insensitive
+ }
+
+ /**
+ * Test: isSensitiveKey correctly identifies non-sensitive keys
+ */
+ @Test
+ public void testIsSensitiveKey_IdentifiesNonSensitiveKeys() {
+ // Test
+ assertFalse(provider.isSensitiveKey("library"));
+ assertFalse(provider.isSensitiveKey("slot_id"));
+ assertFalse(provider.isSensitiveKey("endpoint"));
+ assertFalse(provider.isSensitiveKey("max_sessions"));
+ }
+
+ /**
+ * Test: getProviderName returns correct name
+ */
+ @Test
+ public void testGetProviderName() {
+ assertEquals("pkcs11", provider.getProviderName());
+ }
+
+ /**
+ * Test: createKek requires hsmProfileId
+ */
+ @Test(expected = KMSException.class)
+ public void testCreateKek_RequiresProfileId() throws KMSException {
+ provider.createKek(
+ KeyPurpose.VOLUME_ENCRYPTION,
+ "test-label",
+ 256,
+ null // null profile ID should throw exception
+ );
+ }
+
+ /**
+ * Test: getSessionPool creates pool for new profile
+ */
+ @Test
+ public void testGetSessionPool_CreatesPoolForNewProfile() {
+ // Setup
+ HSMProfileDetailsVO libraryDetail = mock(HSMProfileDetailsVO.class);
+ when(libraryDetail.getName()).thenReturn("library");
+ when(libraryDetail.getValue()).thenReturn("/path/to/lib.so");
+ HSMProfileDetailsVO slotDetail = mock(HSMProfileDetailsVO.class);
+ when(slotDetail.getName()).thenReturn("slot");
+ when(slotDetail.getValue()).thenReturn("1");
+ HSMProfileDetailsVO pinDetail = mock(HSMProfileDetailsVO.class);
+ when(pinDetail.getName()).thenReturn("pin");
+ when(pinDetail.getValue()).thenReturn("1234");
+ when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(
+ Arrays.asList(libraryDetail, slotDetail, pinDetail));
+
+ // Test
+ Object pool = provider.getSessionPool(testProfileId);
+
+ // Verify
+ assertNotNull("Pool should be created", pool);
+ verify(hsmProfileDetailsDao).listByProfileId(testProfileId);
+ }
+
+ /**
+ * Test: getSessionPool reuses pool for same profile
+ */
+ @Test
+ public void testGetSessionPool_ReusesPoolForSameProfile() {
+ // Setup
+ HSMProfileDetailsVO libraryDetail = mock(HSMProfileDetailsVO.class);
+ when(libraryDetail.getName()).thenReturn("library");
+ when(libraryDetail.getValue()).thenReturn("/path/to/lib.so");
+ HSMProfileDetailsVO slotDetail = mock(HSMProfileDetailsVO.class);
+ when(slotDetail.getName()).thenReturn("slot");
+ when(slotDetail.getValue()).thenReturn("1");
+ HSMProfileDetailsVO pinDetail = mock(HSMProfileDetailsVO.class);
+ when(pinDetail.getName()).thenReturn("pin");
+ when(pinDetail.getValue()).thenReturn("1234");
+ when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(
+ Arrays.asList(libraryDetail, slotDetail, pinDetail));
+
+ // Test
+ Object pool1 = provider.getSessionPool(testProfileId);
+ Object pool2 = provider.getSessionPool(testProfileId);
+
+ // Verify
+ assertNotNull("Pool should be created", pool1);
+ assertEquals("Should reuse same pool", pool1, pool2);
+ // Config should only be loaded once
+ verify(hsmProfileDetailsDao, times(1)).listByProfileId(testProfileId);
+ }
+}
diff --git a/plugins/kms/pom.xml b/plugins/kms/pom.xml
new file mode 100644
index 000000000000..8436242447d9
--- /dev/null
+++ b/plugins/kms/pom.xml
@@ -0,0 +1,40 @@
+
+
+
+ 4.0.0
+ cloudstack-kms-plugins
+ pom
+ Apache CloudStack Plugin - KMS
+ Key Management Service providers
+
+
+ org.apache.cloudstack
+ cloudstack-plugins
+ 4.23.0.0-SNAPSHOT
+ ../pom.xml
+
+
+
+ database
+ pkcs11
+
+
diff --git a/plugins/pom.xml b/plugins/pom.xml
index e7d13871285e..4b4aae9479c9 100755
--- a/plugins/pom.xml
+++ b/plugins/pom.xml
@@ -97,6 +97,8 @@
integrations/prometheus
integrations/kubernetes-service
+ kms
+
metrics
network-elements/bigswitch
diff --git a/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java b/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java
index ac8d6a58f0cb..f47a35ced44a 100644
--- a/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java
+++ b/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java
@@ -199,7 +199,7 @@ private UserVm deploySharedFSVM(Long zoneId, Account owner, List networkId
diskOfferingId, size, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST, base64UserData,
null, null, keypairs, null, addrs, null, null, null,
customParameterMap, null, null, null, null,
- true, UserVmManager.SHAREDFSVM, null, null, null);
+ true, UserVmManager.SHAREDFSVM, null, null, null, null);
vmContext.setEventResourceId(vm.getId());
userVmService.startVirtualMachine(vm, null);
} catch (InsufficientCapacityException ex) {
diff --git a/plugins/storage/sharedfs/storagevm/src/test/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycleTest.java b/plugins/storage/sharedfs/storagevm/src/test/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycleTest.java
index c64e8c05c995..82d055b9a359 100644
--- a/plugins/storage/sharedfs/storagevm/src/test/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycleTest.java
+++ b/plugins/storage/sharedfs/storagevm/src/test/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycleTest.java
@@ -257,7 +257,7 @@ public void testDeploySharedFS() throws ResourceUnavailableException, Insufficie
anyString(), anyLong(), anyLong(), any(), isNull(), any(Hypervisor.HypervisorType.class), any(BaseCmd.HTTPMethod.class), anyString(),
isNull(), isNull(), anyList(), isNull(), any(Network.IpAddresses.class), isNull(), isNull(), isNull(),
anyMap(), isNull(), isNull(), isNull(), isNull(),
- anyBoolean(), anyString(), isNull(), isNull(), isNull())).thenReturn(vm);
+ anyBoolean(), anyString(), isNull(), isNull(), isNull(), isNull())).thenReturn(vm);
VolumeVO rootVol = mock(VolumeVO.class);
when(rootVol.getVolumeType()).thenReturn(Volume.Type.ROOT);
diff --git a/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java b/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java
index 5faa377ce3d3..a5e87870eab4 100644
--- a/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java
+++ b/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java
@@ -575,11 +575,25 @@ public void provideVmTags(long vmId, long volumeId, String tagValue) {
*/
private boolean anyVolumeRequiresEncryption(DataObject ... objects) {
for (DataObject o : objects) {
- // this fails code smell for returning true twice, but it is more readable than combining all tests into one statement
- if (o instanceof VolumeInfo && ((VolumeInfo) o).getPassphraseId() != null) {
- return true;
- } else if (o instanceof SnapshotInfo && ((SnapshotInfo) o).getBaseVolume().getPassphraseId() != null) {
- return true;
+ // Check for legacy passphrase-based encryption
+ if (o instanceof VolumeInfo) {
+ VolumeInfo vol = (VolumeInfo) o;
+ if (vol.getPassphraseId() != null) {
+ return true;
+ }
+ // Check for KMS-based encryption
+ if (vol.getKmsWrappedKeyId() != null || vol.getKmsKeyId() != null) {
+ return true;
+ }
+ } else if (o instanceof SnapshotInfo) {
+ VolumeInfo baseVol = ((SnapshotInfo) o).getBaseVolume();
+ if (baseVol.getPassphraseId() != null) {
+ return true;
+ }
+ // Check for KMS-based encryption
+ if (baseVol.getKmsWrappedKeyId() != null || baseVol.getKmsKeyId() != null) {
+ return true;
+ }
}
}
return false;
diff --git a/server/pom.xml b/server/pom.xml
index 2b35a0f42ac8..a44c3af0e73a 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -69,6 +69,11 @@
cloud-framework-ca
${project.version}