From f354da443615af1259dd1d9dd00af55a2d18e900 Mon Sep 17 00:00:00 2001 From: vishesh92 Date: Wed, 21 Jan 2026 14:27:08 +0530 Subject: [PATCH] fixups and some ui changes --- .../apache/cloudstack/api/ApiConstants.java | 1 + .../admin/kms/MigrateVolumesToKMSCmd.java | 12 + .../command/admin/kms/RotateKMSKeyCmd.java | 20 +- .../api/command/user/kms/CreateKMSKeyCmd.java | 27 +- .../api/command/user/kms/DeleteKMSKeyCmd.java | 12 - .../api/command/user/kms/ListKMSKeysCmd.java | 8 - .../api/command/user/kms/UpdateKMSKeyCmd.java | 12 - .../user/kms/hsm/AddHSMProfileCmd.java | 72 +- .../user/kms/hsm/DeleteHSMProfileCmd.java | 14 +- .../user/kms/hsm/ListHSMProfilesCmd.java | 19 +- .../user/kms/hsm/UpdateHSMProfileCmd.java | 14 +- .../org/apache/cloudstack/kms/KMSKey.java | 2 + .../org/apache/cloudstack/kms/KMSManager.java | 41 +- client/pom.xml | 5 + .../org/apache/cloudstack/kms/KMSKeyVO.java | 1 + .../framework/kms/KMSException.java | 4 + .../spring-database-kms-context.xml | 2 - .../provider/pkcs11/PKCS11HSMProvider.java | 1136 +++++++++++++++-- .../pkcs11-kms/spring-pkcs11-kms-context.xml | 9 +- ...ementServerMaintenanceManagerImplTest.java | 2 +- .../apache/cloudstack/kms/KMSManagerImpl.java | 238 ++-- .../cloudstack/kms/KMSManagerImplHSMTest.java | 140 -- .../kms/KMSManagerImplKeyCreationTest.java | 134 +- ui/public/locales/en.json | 5 +- ui/src/config/router.js | 2 + ui/src/config/section/kms.js | 148 +++ ui/src/views/compute/DeployVM.vue | 92 +- .../compute/wizard/DiskSizeSelection.vue | 116 +- 28 files changed, 1523 insertions(+), 765 deletions(-) create mode 100644 ui/src/config/section/kms.js diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index e26ca805c89..cb359c53803 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -868,6 +868,7 @@ public class ApiConstants { public static final String SORT_BY = "sortby"; public static final String CHANGE_CIDR = "changecidr"; public static final String HSM_PROFILE = "hsmprofile"; + public static final String HSM_PROFILE_ID = "hsmprofileid"; public static final String PURPOSE = "purpose"; public static final String KMS_KEY_ID = "kmskeyid"; public static final String KMS_KEY_VERSION = "kmskeyversion"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java index 43e77d28f44..308e1dd38a0 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java @@ -27,6 +27,7 @@ import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.AsyncJobResponse; import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.framework.kms.KMSException; import org.apache.cloudstack.kms.KMSManager; @@ -68,6 +69,13 @@ public class MigrateVolumesToKMSCmd extends BaseAsyncCmd { description = "Domain ID") private Long domainId; + @Parameter(name = ApiConstants.ID, + required = true, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "KMS Key ID to use for migrating volumes") + private Long kmsKeyId; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -84,6 +92,10 @@ public class MigrateVolumesToKMSCmd extends BaseAsyncCmd { return domainId; } + public Long getKmsKeyId() { + return kmsKeyId; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/RotateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/RotateKMSKeyCmd.java index 0a9da02a543..ffe0bc32ab3 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/RotateKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/RotateKMSKeyCmd.java @@ -26,6 +26,7 @@ import org.apache.cloudstack.api.BaseAsyncCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.AsyncJobResponse; +import org.apache.cloudstack.api.response.HSMProfileResponse; import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.framework.kms.KMSException; import org.apache.cloudstack.kms.KMSManager; @@ -45,10 +46,6 @@ public class RotateKMSKeyCmd extends BaseAsyncCmd { @Inject private KMSManager kmsManager; - ///////////////////////////////////////////////////// - //////////////// API parameters ///////////////////// - ///////////////////////////////////////////////////// - @Parameter(name = ApiConstants.ID, required = true, type = CommandType.UUID, @@ -61,15 +58,12 @@ public class RotateKMSKeyCmd extends BaseAsyncCmd { description = "Key size for new KEK (default: same as current)") private Integer keyBits; - @Parameter(name = ApiConstants.HSM_PROFILE, - type = CommandType.STRING, - description = "The target HSM profile name for the new KEK version. If provided, migrates the key to this HSM.") + @Parameter(name = ApiConstants.HSM_PROFILE_ID, + type = CommandType.UUID, + entityType = HSMProfileResponse.class, + description = "The target HSM profile ID for the new KEK version. If provided, migrates the key to this HSM.") private String hsmProfile; - ///////////////////////////////////////////////////// - /////////////////// Accessors /////////////////////// - ///////////////////////////////////////////////////// - public Long getId() { return id; } @@ -82,10 +76,6 @@ public class RotateKMSKeyCmd extends BaseAsyncCmd { return hsmProfile; } - ///////////////////////////////////////////////////// - /////////////// API Implementation/////////////////// - ///////////////////////////////////////////////////// - @Override public void execute() { try { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java index 1a1484e0ba0..5c2b53744b8 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java @@ -31,6 +31,7 @@ import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.HSMProfileResponse; import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.context.CallContext; @@ -51,10 +52,6 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { @Inject private KMSManager kmsManager; - ///////////////////////////////////////////////////// - //////////////// API parameters ///////////////////// - ///////////////////////////////////////////////////// - @Parameter(name = ApiConstants.NAME, required = true, type = CommandType.STRING, @@ -95,14 +92,12 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { description = "Key size in bits: 128, 192, or 256 (default: 256)") private Integer keyBits; - @Parameter(name = ApiConstants.HSM_PROFILE, - type = CommandType.STRING, - description = "Name of HSM profile to create key in") - private String hsmProfile; - - ///////////////////////////////////////////////////// - /////////////////// Accessors /////////////////////// - ///////////////////////////////////////////////////// + @Parameter(name = ApiConstants.HSM_PROFILE_ID, + type = CommandType.UUID, + entityType = HSMProfileResponse.class, + required = true, + description = "ID of HSM profile to create key in") + private Long hsmProfileId; public String getName() { return name; @@ -132,14 +127,10 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { return keyBits != null ? keyBits : 256; // Default to 256 bits } - public String getHsmProfile() { - return hsmProfile; + public Long getHsmProfileId() { + return hsmProfileId; } - ///////////////////////////////////////////////////// - /////////////// API Implementation/////////////////// - ///////////////////////////////////////////////////// - @Override public void execute() throws ResourceAllocationException { try { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java index bd6a4bd1fd6..10e982709b6 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java @@ -49,10 +49,6 @@ public class DeleteKMSKeyCmd extends BaseAsyncCmd implements UserCmd { @Inject private KMSManager kmsManager; - ///////////////////////////////////////////////////// - //////////////// API parameters ///////////////////// - ///////////////////////////////////////////////////// - @Parameter(name = ApiConstants.ID, required = true, type = CommandType.UUID, @@ -60,18 +56,10 @@ public class DeleteKMSKeyCmd extends BaseAsyncCmd implements UserCmd { description = "The UUID of the KMS key to delete") private Long id; - ///////////////////////////////////////////////////// - /////////////////// Accessors /////////////////////// - ///////////////////////////////////////////////////// - public Long getId() { return id; } - ///////////////////////////////////////////////////// - /////////////// API Implementation/////////////////// - ///////////////////////////////////////////////////// - @Override public void execute() { try { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java index a428854e6a3..1bb2e38853a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java @@ -47,10 +47,6 @@ public class ListKMSKeysCmd extends BaseListAccountResourcesCmd implements UserC @Inject private KMSManager kmsManager; - ///////////////////////////////////////////////////// - //////////////// API parameters ///////////////////// - ///////////////////////////////////////////////////// - @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = KMSKeyResponse.class, @@ -73,10 +69,6 @@ public class ListKMSKeysCmd extends BaseListAccountResourcesCmd implements UserC description = "Filter by state: Enabled, Disabled") private String state; - ///////////////////////////////////////////////////// - /////////////////// Accessors /////////////////////// - ///////////////////////////////////////////////////// - public Long getId() { return id; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java index 673fb0e719b..d4d83a08cc7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java @@ -48,10 +48,6 @@ public class UpdateKMSKeyCmd extends BaseAsyncCmd implements UserCmd { @Inject private KMSManager kmsManager; - ///////////////////////////////////////////////////// - //////////////// API parameters ///////////////////// - ///////////////////////////////////////////////////// - @Parameter(name = ApiConstants.ID, required = true, type = CommandType.UUID, @@ -74,10 +70,6 @@ public class UpdateKMSKeyCmd extends BaseAsyncCmd implements UserCmd { description = "New state: Enabled or Disabled") private String state; - ///////////////////////////////////////////////////// - /////////////////// Accessors /////////////////////// - ///////////////////////////////////////////////////// - public Long getId() { return id; } @@ -94,10 +86,6 @@ public class UpdateKMSKeyCmd extends BaseAsyncCmd implements UserCmd { return state; } - ///////////////////////////////////////////////////// - /////////////// API Implementation/////////////////// - ///////////////////////////////////////////////////// - @Override public void execute() { try { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/AddHSMProfileCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/AddHSMProfileCmd.java index 828b2198863..4aad0811aff 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/AddHSMProfileCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/AddHSMProfileCmd.java @@ -17,16 +17,18 @@ package org.apache.cloudstack.api.command.user.kms.hsm; -import java.util.Map; - -import javax.inject.Inject; - +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AccountResponse; import org.apache.cloudstack.api.response.DomainResponse; import org.apache.cloudstack.api.response.HSMProfileResponse; import org.apache.cloudstack.api.response.ZoneResponse; @@ -34,55 +36,56 @@ import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.kms.KMSException; import org.apache.cloudstack.kms.HSMProfile; import org.apache.cloudstack.kms.KMSManager; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang.StringUtils; -import com.cloud.exception.ConcurrentOperationException; -import com.cloud.exception.InsufficientCapacityException; -import com.cloud.exception.NetworkRuleConflictException; -import com.cloud.exception.ResourceAllocationException; -import com.cloud.exception.ResourceUnavailableException; -import com.cloud.user.Account; +import javax.inject.Inject; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; @APICommand(name = "addHSMProfile", description = "Adds a new HSM profile", responseObject = HSMProfileResponse.class, - requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, since = "4.21.0") + requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, since = "4.23.0") public class AddHSMProfileCmd extends BaseCmd { @Inject private KMSManager kmsManager; - ////////////////////////////////////////////////===== - // API parameters - ////////////////////////////////////////////////===== - - @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, description = "the name of the HSM profile") + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, + description = "the name of the HSM profile") private String name; - @Parameter(name = ApiConstants.PROTOCOL, type = CommandType.STRING, required = true, description = "the protocol of the HSM profile (PKCS11, KMIP, etc.)") + @Parameter(name = ApiConstants.PROTOCOL, type = CommandType.STRING, + description = "the protocol of the HSM profile (PKCS11, KMIP, etc.). Default is 'pkcs11'") private String protocol; - @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, description = "the zone ID where the HSM profile is available. If null, global scope (for admin only)") + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + description = "the zone ID where the HSM profile is available. If null, global scope (for admin only)") private Long zoneId; - @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, description = "the domain ID where the HSM profile is available") + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, + description = "the domain ID where the HSM profile is available") private Long domainId; - @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = DomainResponse.class, description = "the account ID of the HSM profile owner. If null, admin-provided (available to all accounts)") + @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, + description = "the account ID of the HSM profile owner. If null, admin-provided (available to all " + + "accounts)") private Long accountId; @Parameter(name = ApiConstants.VENDOR_NAME, type = CommandType.STRING, description = "the vendor name of the HSM") private String vendorName; - @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, required = true, description = "HSM configuration details (protocol specific)") + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, description = "HSM configuration details (protocol specific)") private Map details; - ////////////////////////////////////////////////===== - // Accessors - ////////////////////////////////////////////////===== - public String getName() { return name; } public String getProtocol() { + if (StringUtils.isBlank(protocol)) { + return "pkcs11"; + } return protocol; } @@ -103,15 +106,22 @@ public class AddHSMProfileCmd extends BaseCmd { } public Map getDetails() { - return details; + Map detailsMap = new HashMap<>(); + if (MapUtils.isNotEmpty(details)) { + Collection props = details.values(); + for (Object prop : props) { + HashMap detail = (HashMap) prop; + for (Map.Entry entry: detail.entrySet()) { + detailsMap.put(entry.getKey(),entry.getValue()); + } + } + } + return detailsMap; } - ////////////////////////////////////////////////===== - // Implementation - ////////////////////////////////////////////////===== - @Override - public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { try { // Default to caller account if not admin and accountId not specified // But wait, the plan says: "No accountId parameter means account_id = NULL (admin-provided)" diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java index 6c323d52725..da264e92deb 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java @@ -39,31 +39,19 @@ import com.cloud.exception.ResourceAllocationException; import com.cloud.exception.ResourceUnavailableException; @APICommand(name = "deleteHSMProfile", description = "Deletes an HSM profile", responseObject = SuccessResponse.class, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.21.0") + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.23.0") public class DeleteHSMProfileCmd extends BaseCmd { @Inject private KMSManager kmsManager; - ////////////////////////////////////////////////===== - // API parameters - ////////////////////////////////////////////////===== - @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, required = true, description = "the ID of the HSM profile") private Long id; - ////////////////////////////////////////////////===== - // Accessors - ////////////////////////////////////////////////===== - public Long getId() { return id; } - ////////////////////////////////////////////////===== - // Implementation - ////////////////////////////////////////////////===== - @Override public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { try { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java index 95650c60ce6..145c65b9922 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java @@ -33,15 +33,14 @@ import org.apache.cloudstack.kms.HSMProfile; import org.apache.cloudstack.kms.KMSManager; @APICommand(name = "listHSMProfiles", description = "Lists HSM profiles", responseObject = HSMProfileResponse.class, - requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, since = "4.21.0") + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, since = "4.23.0") public class ListHSMProfilesCmd extends BaseListCmd { @Inject private KMSManager kmsManager; - ////////////////////////////////////////////////===== - // API parameters - ////////////////////////////////////////////////===== + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, description = "the HSM profile ID") + private Long id; @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, description = "the zone ID") private Long zoneId; @@ -52,9 +51,9 @@ public class ListHSMProfilesCmd extends BaseListCmd { @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "list only enabled profiles") private Boolean enabled; - ////////////////////////////////////////////////===== - // Accessors - ////////////////////////////////////////////////===== + public Long getId() { + return id; + } public Long getZoneId() { return zoneId; @@ -68,16 +67,12 @@ public class ListHSMProfilesCmd extends BaseListCmd { return enabled; } - ////////////////////////////////////////////////===== - // Implementation - ////////////////////////////////////////////////===== - @Override public void execute() { List profiles = kmsManager.listHSMProfiles(this); ListResponse response = new ListResponse<>(); List profileResponses = new ArrayList<>(); - + for (HSMProfile profile : profiles) { HSMProfileResponse profileResponse = kmsManager.createHSMProfileResponse(profile); profileResponses.add(profileResponse); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.java index 1b67d87e068..8b3f39b2c2d 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.java @@ -40,16 +40,12 @@ import com.cloud.exception.ResourceAllocationException; import com.cloud.exception.ResourceUnavailableException; @APICommand(name = "updateHSMProfile", description = "Updates an HSM profile", responseObject = HSMProfileResponse.class, - requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, since = "4.21.0") + requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, since = "4.23.0") public class UpdateHSMProfileCmd extends BaseCmd { @Inject private KMSManager kmsManager; - ////////////////////////////////////////////////===== - // API parameters - ////////////////////////////////////////////////===== - @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, required = true, description = "the ID of the HSM profile") private Long id; @@ -62,10 +58,6 @@ public class UpdateHSMProfileCmd extends BaseCmd { @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, description = "HSM configuration details to update (protocol specific)") private Map details; - ////////////////////////////////////////////////===== - // Accessors - ////////////////////////////////////////////////===== - public Long getId() { return id; } @@ -82,10 +74,6 @@ public class UpdateHSMProfileCmd extends BaseCmd { return details; } - ////////////////////////////////////////////////===== - // Implementation - ////////////////////////////////////////////////===== - @Override public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { try { diff --git a/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java b/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java index d0397df8180..6c388d362a5 100644 --- a/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java +++ b/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java @@ -100,4 +100,6 @@ public interface KMSKey extends Identity, InternalIdentity, ControlledEntity { /** Key is soft-deleted */ Deleted } + + Long getHsmProfileId(); } diff --git a/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java b/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java index a4fbe7c6fa8..ee64543b702 100644 --- a/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java +++ b/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java @@ -50,20 +50,6 @@ public interface KMSManager extends Manager, Configurable { // ==================== Configuration Keys ==================== - /** - * Global: which KMS provider plugin to use by default - * Supported values: "database" (default), "pkcs11", or custom provider names - */ - ConfigKey KMSProviderPlugin = new ConfigKey<>( - "Advanced", - String.class, - "kms.provider.plugin", - "database", - "The KMS provider plugin to use for cryptographic operations (database, pkcs11, etc.)", - true, - ConfigKey.Scope.Global - ); - /** * Zone-scoped: enable KMS for a specific zone * When false (default), new volumes use legacy passphrase encryption @@ -209,23 +195,6 @@ public interface KMSManager extends Manager, Configurable { // ==================== User KEK Management ==================== - /** - * Create a new KMS key (KEK) for a user account - * - * @param accountId the account ID - * @param domainId the domain ID - * @param zoneId the zone ID - * @param name user-friendly name - * @param description optional description - * @param purpose key purpose - * @param keyBits key size in bits - * @return the created KMS key - * @throws KMSException if creation fails - */ - KMSKey createUserKMSKey(Long accountId, Long domainId, Long zoneId, - String name, String description, KeyPurpose purpose, - Integer keyBits) throws KMSException; - /** * List KMS keys accessible to a user account * @@ -341,7 +310,7 @@ public interface KMSManager extends Manager, Configurable { /** * Add a new HSM profile - * + * * @param cmd the add command * @return the created HSM profile * @throws KMSException if addition fails @@ -350,7 +319,7 @@ public interface KMSManager extends Manager, Configurable { /** * List HSM profiles - * + * * @param cmd the list command * @return list of HSM profiles */ @@ -358,7 +327,7 @@ public interface KMSManager extends Manager, Configurable { /** * Delete an HSM profile - * + * * @param cmd the delete command * @return true if deletion was successful * @throws KMSException if deletion fails @@ -367,7 +336,7 @@ public interface KMSManager extends Manager, Configurable { /** * Update an HSM profile - * + * * @param cmd the update command * @return the updated HSM profile * @throws KMSException if update fails @@ -376,7 +345,7 @@ public interface KMSManager extends Manager, Configurable { /** * Create a response object for an HSM profile - * + * * @param profile the HSM profile * @return the response object */ diff --git a/client/pom.xml b/client/pom.xml index 30ae1612301..979a6068207 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -256,6 +256,11 @@ cloud-plugin-kms-database ${project.version} + + org.apache.cloudstack + cloud-plugin-kms-pkcs11 + ${project.version} + org.apache.cloudstack cloud-plugin-network-nvp diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java index d65d5259ccb..1bd770861e0 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java @@ -252,6 +252,7 @@ public class KMSKeyVO implements KMSKey { this.state = state; } + @Override public Long getHsmProfileId() { return hsmProfileId; } 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 index 2d479bf0ab3..977e7e62e8e 100644 --- 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 @@ -30,6 +30,10 @@ public class KMSException extends CloudRuntimeException { */ public enum ErrorType { CONNECTION_FAILED(true), + /** + * Authentication failed (e.g., incorrect PIN) + */ + AUTHENTICATION_FAILED(false), /** * Provider not initialized or unavailable */ 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 index 5ec8d157918..186e8adfa71 100644 --- 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 @@ -21,8 +21,6 @@ xmlns="http://www.springframework.org/schema/beans" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd - - http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd" > 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 index 2b6d557080a..e1d059258bd 100644 --- 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 @@ -17,21 +17,8 @@ package org.apache.cloudstack.kms.provider.pkcs11; -import java.security.KeyStore; -import java.security.Provider; -import java.security.Security; -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 javax.annotation.PostConstruct; -import javax.inject.Inject; - +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; @@ -45,30 +32,65 @@ 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 org.springframework.stereotype.Component; -import com.cloud.utils.component.AdapterBase; -import com.cloud.utils.crypt.DBEncryptionUtil; +import javax.annotation.PostConstruct; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +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; -@Component public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { private static final Logger logger = LogManager.getLogger(PKCS11HSMProvider.class); private static final String PROVIDER_NAME = "pkcs11"; - @Inject - private HSMProfileDao hsmProfileDao; - - @Inject - private HSMProfileDetailsDao hsmProfileDetailsDao; - - @Inject - private KMSKekVersionDao kmsKekVersionDao; + // Constants for session management + private static final long SESSION_ACQUIRE_TIMEOUT_MS = 5000L; + private static final int MAX_SESSION_RETRIES = 3; + private static final long RETRY_BACKOFF_BASE_MS = 100L; + // Valid key sizes for AES + private static final int[] VALID_KEY_SIZES = {128, 192, 256}; // Session pool per HSM profile private final Map sessionPools = new ConcurrentHashMap<>(); - // Profile configuration caching private final Map> profileConfigCache = new ConcurrentHashMap<>(); + @Inject + private HSMProfileDao hsmProfileDao; + @Inject + private HSMProfileDetailsDao hsmProfileDetailsDao; + @Inject + private KMSKekVersionDao kmsKekVersionDao; @PostConstruct public void init() { @@ -80,21 +102,6 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { return PROVIDER_NAME; } - /** - * @return The name of the component that provided this configuration - * variable. This value is saved in the database so someone can easily - * identify who provides this variable. - **/ - @Override - public String getConfigComponentName() { - return PKCS11HSMProvider.class.getSimpleName(); - } - - @Override - public ConfigKey[] getConfigKeys() { - return new ConfigKey[0]; - } - @Override public String createKek(KeyPurpose purpose, String label, int keyBits, Long hsmProfileId) throws KMSException { if (hsmProfileId == null) { @@ -116,20 +123,35 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } @Override - public WrappedKey wrapKey(byte[] plainDek, KeyPurpose purpose, String kekLabel, Long hsmProfileId) throws KMSException { + public void deleteKek(String kekId) throws KMSException { + Long hsmProfileId = resolveProfileId(kekId); + executeWithSession(hsmProfileId, session -> { + session.deleteKey(kekId); + return null; + }); + } + + @Override + public boolean isKekAvailable(String kekId) throws KMSException { + Long hsmProfileId = resolveProfileId(kekId); + if (hsmProfileId == null) return false; + + try { + 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); } - HSMSessionPool pool = getSessionPool(hsmProfileId); - PKCS11Session session = null; - try { - session = pool.acquireSession(5000); - byte[] wrappedBlob = session.wrapKey(plainDek, kekLabel); - return new WrappedKey(kekLabel, purpose, "AES/GCM/NoPadding", wrappedBlob, PROVIDER_NAME, new Date(), null); - } finally { - pool.releaseSession(session); - } + byte[] wrappedBlob = executeWithSession(hsmProfileId, session -> session.wrapKey(plainDek, kekLabel)); + return new WrappedKey(kekLabel, purpose, "AES/GCM/NoPadding", wrappedBlob, PROVIDER_NAME, new Date(), null); } @Override @@ -138,18 +160,27 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { hsmProfileId = resolveProfileId(wrappedKey.getKekId()); } - HSMSessionPool pool = getSessionPool(hsmProfileId); - PKCS11Session session = null; + return executeWithSession(hsmProfileId, + session -> session.unwrapKey(wrappedKey.getWrappedKeyMaterial(), wrappedKey.getKekId())); + } + + @Override + public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits, + Long hsmProfileId) throws KMSException { + // Generate random DEK + byte[] dekBytes = new byte[keyBits / 8]; + new SecureRandom().nextBytes(dekBytes); + try { - session = pool.acquireSession(5000); - return session.unwrapKey(wrappedKey.getWrappedKeyMaterial(), wrappedKey.getKekId()); + return wrapKey(dekBytes, purpose, kekLabel, hsmProfileId); } finally { - pool.releaseSession(session); + java.util.Arrays.fill(dekBytes, (byte) 0); } } @Override - public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel, Long targetHsmProfileId) throws KMSException { + public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel, + Long targetHsmProfileId) throws KMSException { // 1. Unwrap with old KEK byte[] plainKey = unwrapKey(oldWrappedKey, null); // Auto-resolve old profile @@ -167,52 +198,76 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } } - @Override - public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits, Long hsmProfileId) throws KMSException { - // Generate random DEK - byte[] dekBytes = new byte[keyBits / 8]; - new java.security.SecureRandom().nextBytes(dekBytes); - - try { - return wrapKey(dekBytes, purpose, kekLabel, hsmProfileId); - } finally { - java.util.Arrays.fill(dekBytes, (byte) 0); - } - } - - @Override - public void deleteKek(String kekId) throws KMSException { - Long hsmProfileId = resolveProfileId(kekId); - HSMSessionPool pool = getSessionPool(hsmProfileId); - PKCS11Session session = null; - try { - session = pool.acquireSession(5000); - session.deleteKey(kekId); - } finally { - pool.releaseSession(session); - } - } - - @Override - public boolean isKekAvailable(String kekId) throws KMSException { - Long hsmProfileId = resolveProfileId(kekId); - if (hsmProfileId == null) return false; - - HSMSessionPool pool = getSessionPool(hsmProfileId); - PKCS11Session session = null; - try { - session = pool.acquireSession(5000); - return session.checkKeyExists(kekId); - } catch (Exception e) { - return false; - } finally { - pool.releaseSession(session); - } - } - + /** + * Performs health check on all configured HSM profiles. + * + *

For each configured HSM profile: + *

    + *
  1. Attempts to acquire a test session
  2. + *
  3. Verifies HSM is responsive (lightweight KeyStore operation)
  4. + *
  5. Releases the session
  6. + *
+ * + *

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 { - return true; + // Test connectivity to at least one configured HSM profile + if (sessionPools.isEmpty()) { + logger.debug("No HSM profiles configured for health check"); + return true; // No profiles means nothing to check + } + + boolean allHealthy = true; + for (Map.Entry entry : sessionPools.entrySet()) { + Long profileId = entry.getKey(); + HSMSessionPool pool = entry.getValue(); + if (!checkProfileHealth(profileId, pool)) { + allHealthy = false; + } + } + + if (!allHealthy) { + throw KMSException.healthCheckFailed("One or more HSM profiles failed health check", null); + } + + return allHealthy; + } + + /** + * Checks health of a single HSM profile. + * + * @param profileId HSM profile ID + * @param pool Session pool for the profile + * @return true if profile is healthy, false otherwise + */ + private boolean checkProfileHealth(Long profileId, HSMSessionPool pool) { + try { + PKCS11Session testSession = pool.acquireSession(SESSION_ACQUIRE_TIMEOUT_MS); + if (testSession == null || !testSession.isValid()) { + logger.warn("Health check failed for HSM profile {}: Could not acquire valid session", profileId); + return false; + } + try { + if (testSession.keyStore != null) { + testSession.keyStore.size(); // Lightweight operation + logger.debug("Health check passed for HSM profile {}", profileId); + return true; + } else { + logger.warn("Health check failed for HSM profile {}: KeyStore is null", profileId); + return false; + } + } finally { + pool.releaseSession(testSession); + } + } catch (Exception e) { + logger.warn("Health check failed for HSM profile {}: {}", profileId, e.getMessage(), e); + return false; + } } Long resolveProfileId(String kekLabel) throws KMSException { @@ -220,12 +275,32 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { 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); + throw new KMSException(KMSException.ErrorType.KEK_NOT_FOUND, + "Could not resolve HSM profile for KEK: " + kekLabel); + } + + /** + * Executes an operation with a session from the pool, handling acquisition and release. + * + * @param hsmProfileId HSM profile ID + * @param operation Operation to execute with the session + * @return Result of the operation + * @throws KMSException if session acquisition fails or operation throws an exception + */ + private 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 -> new HSMSessionPool(id, loadProfileConfig(id))); + id -> new HSMSessionPool(id, loadProfileConfig(id))); } Map loadProfileConfig(Long profileId) { @@ -239,10 +314,121 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } config.put(detail.getName(), value); } + // Validate configuration + validateProfileConfig(config); return config; }); } + /** + * Validates HSM profile configuration for PKCS#11 provider. + * + *

Validates: + *

    + *
  • {@code library}: Required, should point to PKCS#11 library
  • + *
  • {@code slot} or {@code token_label}: At least one required
  • + *
  • {@code pin}: Required for HSM authentication
  • + *
  • {@code max_sessions}: Optional, must be positive integer if provided
  • + *
  • {@code min_idle_sessions}: Optional, must be non-negative integer if provided
  • + *
+ * + * @param config Configuration map from HSM profile details + * @throws KMSException with {@code INVALID_PARAMETER} if validation fails + */ + void validateProfileConfig(Map config) throws KMSException { + // Validate required config keys + String libraryPath = config.get("library"); + if (StringUtils.isEmpty(libraryPath)) { + throw KMSException.invalidParameter("library is required for PKCS#11 HSM profile"); + } + + // Validate slot or token_label (at least one required) + String slot = config.get("slot"); + String tokenLabel = config.get("token_label"); + if (StringUtils.isEmpty(slot) && StringUtils.isEmpty(tokenLabel)) { + throw KMSException.invalidParameter("Either 'slot' or 'token_label' is required for PKCS#11 HSM profile"); + } + + // Validate slot is numeric if provided + if (!StringUtils.isEmpty(slot)) { + try { + Integer.parseInt(slot); + } catch (NumberFormatException e) { + throw KMSException.invalidParameter("slot must be a valid integer: " + slot); + } + } + + // Validate PIN is present + String pin = config.get("pin"); + if (StringUtils.isEmpty(pin)) { + throw KMSException.invalidParameter("pin is required for PKCS#11 HSM profile"); + } + + // Validate library points to existing file (if accessible) + File libraryFile = new File(libraryPath); + if (!libraryFile.exists() && !libraryFile.isAbsolute()) { + // Try to find in common library paths, but don't fail if not found + // The HSM library might be in system library path + logger.debug("Library path {} does not exist as absolute path, will rely on system library path", + libraryPath); + } + + // Validate max_sessions and min_idle_sessions if provided + parsePositiveInteger(config, "max_sessions", "max_sessions"); + parseNonNegativeInteger(config, "min_idle_sessions", "min_idle_sessions"); + } + + /** + * Parses a positive integer from configuration. + * + * @param config Configuration map + * @param key Configuration key + * @param errorPrefix Prefix for error messages + * @return Parsed integer value, or -1 if not provided + * @throws KMSException if value is invalid or not positive + */ + private int parsePositiveInteger(Map config, String key, String errorPrefix) throws KMSException { + String value = config.get(key); + if (StringUtils.isEmpty(value)) { + return -1; // Not provided + } + try { + int parsed = Integer.parseInt(value); + if (parsed <= 0) { + throw KMSException.invalidParameter(errorPrefix + " must be greater than 0"); + } + return parsed; + } catch (NumberFormatException e) { + throw KMSException.invalidParameter(errorPrefix + " must be a valid integer: " + value); + } + } + + /** + * Parses a non-negative integer from configuration. + * + * @param config Configuration map + * @param key Configuration key + * @param errorPrefix Prefix for error messages + * @return Parsed integer value, or -1 if not provided + * @throws KMSException if value is invalid or negative + */ + private int parseNonNegativeInteger(Map config, String key, + String errorPrefix) throws KMSException { + String value = config.get(key); + if (StringUtils.isEmpty(value)) { + return -1; // Not provided + } + try { + int parsed = Integer.parseInt(value); + if (parsed < 0) { + throw KMSException.invalidParameter(errorPrefix + " must be non-negative"); + } + return parsed; + } catch (NumberFormatException e) { + throw KMSException.invalidParameter(errorPrefix + " must be a valid integer: " + value); + } + } + boolean isSensitiveKey(String key) { return key.equalsIgnoreCase("pin") || key.equalsIgnoreCase("password") || @@ -254,6 +440,29 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { return purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); } + /** + * @return The name of the component that provided this configuration + * variable. This value is saved in the database so someone can easily + * identify who provides this variable. + **/ + @Override + public String getConfigComponentName() { + return PKCS11HSMProvider.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[0]; + } + + /** + * Functional interface for operations that require a PKCS#11 session. + */ + @FunctionalInterface + private interface SessionOperation { + T execute(PKCS11Session session) throws KMSException; + } + // Inner class for session pooling private static class HSMSessionPool { private final BlockingQueue availableSessions; @@ -279,19 +488,47 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } } + private PKCS11Session createNewSession() throws KMSException { + return new PKCS11Session(config); + } + PKCS11Session acquireSession(long timeoutMs) throws KMSException { - try { - PKCS11Session session = availableSessions.poll(); - if (session == null || !session.isValid()) { - if (session != null) { - session.close(); + // Retry logic for session creation + Exception lastException = null; + + for (int attempt = 0; attempt < MAX_SESSION_RETRIES; attempt++) { + try { + PKCS11Session session = availableSessions.poll(); + if (session == null || !session.isValid()) { + if (session != null) { + session.close(); + } + session = createNewSession(); + } + return session; + } catch (Exception e) { + lastException = e; + if (attempt < MAX_SESSION_RETRIES - 1) { + // Exponential backoff: 100ms, 200ms, 400ms + long backoffMs = RETRY_BACKOFF_BASE_MS * (1L << attempt); + try { + Thread.sleep(backoffMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + "Interrupted while waiting to retry HSM session acquisition", ie); + } + logger.debug("Retrying HSM session acquisition for profile {} (attempt {}/{})", + profileId, attempt + 2, MAX_SESSION_RETRIES); } - session = createNewSession(); } - return session; - } catch (Exception e) { - throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, "Failed to acquire HSM session", e); } + + // All retries failed + logger.error("Failed to acquire HSM session for profile {} after {} attempts", profileId, + MAX_SESSION_RETRIES); + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + "Failed to acquire HSM session after " + MAX_SESSION_RETRIES + " attempts", lastException); } void releaseSession(PKCS11Session session) { @@ -301,63 +538,692 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { } } } - - private PKCS11Session createNewSession() throws KMSException { - return new PKCS11Session(config); - } } - // Inner class representing a PKCS#11 session + /** + * 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-GCM)
  • + *
  • Key unwrapping: Decrypt DEKs using KEKs stored in the HSM (AES-GCM)
  • + *
  • 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 String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_IV_LENGTH = 12; // 96 bits + private static final int GCM_TAG_LENGTH = 16; // 128 bits + private static final String PROVIDER_PREFIX = "CloudStackPKCS11-"; + private final Map config; private KeyStore keyStore; private Provider provider; + private String providerName; + private Path tempConfigFile; + /** + * Creates a new PKCS#11 session and connects to the HSM. + * + * @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 { this.config = config; connect(); } + /** + * Establishes connection to the PKCS#11 HSM. + * + *

This method: + *

    + *
  1. Validates required configuration (library, slot/token_label, pin)
  2. + *
  3. Creates a SunPKCS11 provider with the HSM library
  4. + *
  5. Loads the PKCS#11 KeyStore
  6. + *
  7. Authenticates using the provided PIN
  8. + *
+ * + *

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() throws KMSException { try { - String libraryPath = config.get("library_path"); - // In real implementation: - // Configure SunPKCS11 provider with library path - // Login to keystore - logger.debug("Simulating PKCS#11 connection to " + libraryPath); + // Create unique provider name to avoid conflicts + providerName = PROVIDER_PREFIX + UUID.randomUUID().toString().substring(0, 8); + + String configString = buildSunPKCS11Config(config); + + // For Java 9+, use the recommended approach: get provider and configure with file + // Write config to temporary file (required by Java 9+ API) + tempConfigFile = Files.createTempFile("pkcs11-config-", ".cfg"); + try (FileWriter writer = new FileWriter(tempConfigFile.toFile(), StandardCharsets.UTF_8)) { + writer.write(configString); + } + + // Get the base SunPKCS11 provider and configure it + Provider baseProvider = Security.getProvider("SunPKCS11"); + if (baseProvider == null) { + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + "SunPKCS11 provider not available in this JVM"); + } + + // Configure the provider with the config file (Java 9+ API) + provider = baseProvider.configure(tempConfigFile.toAbsolutePath().toString()); + + // Set the provider name (it will be based on the 'name' field in config) + // Add provider to Security if not already present + if (Security.getProvider(providerName) == null) { + Security.addProvider(provider); + } else { + provider = Security.getProvider(providerName); + } + + // Load PKCS#11 KeyStore + keyStore = KeyStore.getInstance("PKCS11", provider); + + // Get PIN for authentication + String pin = config.get("pin"); + if (StringUtils.isEmpty(pin)) { + throw KMSException.invalidParameter("pin is required"); + } + char[] pinChars = pin.toCharArray(); + + // Load KeyStore with PIN (this authenticates to the HSM) + keyStore.load(null, pinChars); + + // Zeroize PIN from memory + Arrays.fill(pinChars, '\0'); + + 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) { - throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, "Failed to connect to HSM: " + e.getMessage(), 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) throws KMSException { + String libraryPath = config.get("library"); + if (StringUtils.isEmpty(libraryPath)) { + throw KMSException.invalidParameter("library is required"); + } + + StringBuilder configBuilder = new StringBuilder(); + configBuilder.append("name=CloudStackHSM\n"); + configBuilder.append("library=").append(libraryPath).append("\n"); + + String tokenLabel = config.get("token_label"); + String slot = config.get("slot"); + + if (!StringUtils.isEmpty(tokenLabel)) { + configBuilder.append("tokenLabel=").append(tokenLabel).append("\n"); + } else if (!StringUtils.isEmpty(slot)) { + configBuilder.append("slot=").append(slot).append("\n"); + } else { + throw KMSException.invalidParameter("Either 'slot' or 'token_label' is required"); + } + + 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); + + // Map PKCS#11 error codes to KMSException types + 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() { - return true; - } + try { + // Check if KeyStore object is not null + if (keyStore == null) { + return false; + } - void close() { - if (provider != null) { - Security.removeProvider(provider.getName()); + // Check if Provider is still registered in Security + if (provider == null || Security.getProvider(provider.getName()) == null) { + return false; + } + + // Test with a lightweight HSM operation (get KeyStore size) + 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. + * + *

This method: + *

    + *
  1. Closes the KeyStore (if it implements Closeable)
  2. + *
  3. Logs out from the HSM token
  4. + *
  5. Removes the provider from Security
  6. + *
  7. Clears all references
  8. + *
+ * + *

Note: Errors during cleanup are logged but do not throw exceptions + * to ensure cleanup continues even if some steps fail. + */ + void close() { + try { + // Close KeyStore if it implements Closeable + if (keyStore instanceof Closeable) { + ((Closeable) keyStore).close(); + } + + // Logout from HSM token (if supported) + // Note: SunPKCS11 KeyStore doesn't have explicit logout, but closing should handle it + + // Remove provider from Security + if (provider != null && providerName != null) { + try { + Security.removeProvider(providerName); + } catch (Exception e) { + logger.debug("Failed to remove provider {}: {}", providerName, e.getMessage()); + } + } + + // Clean up temporary config file + 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. + * + *

Implementation note: Due to limitations in the Java PKCS#11 API, this method: + *

    + *
  1. Generates a secure random key in software using SecureRandom
  2. + *
  3. Imports it into the HSM via KeyStore.setEntry() with the label
  4. + *
  5. Clears the key material from memory immediately
  6. + *
+ * + *

While the key is briefly in software memory, this is necessary because: + *

    + *
  • Java's PKCS#11 provider doesn't support setting CKA_LABEL during generation
  • + *
  • Keys generated via KeyGenerator have no label and can't be retrieved later
  • + *
  • The key material is immediately cleared after import
  • + *
+ * + *

Once imported, 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 { - return label; + validateKeySize(keyBits); + + byte[] keyBytes = null; + 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 cryptographically secure random key material + // Using SecureRandom instead of HSM generation due to Java PKCS#11 API limitations + keyBytes = new byte[keyBits / 8]; + SecureRandom.getInstanceStrong().nextBytes(keyBytes); + + // Wrap key bytes in a SecretKeySpec for import into HSM + SecretKey secretKey = new SecretKeySpec(keyBytes, "AES"); + + // Import into PKCS#11 KeyStore with label + // Uses setKeyEntry(String, Key, char[], Certificate[]) which is the only + // variant supported by P11KeyStore (the byte[] variant throws UnsupportedOperationException) + // The P11KeyStore will internally convert the SecretKeySpec to a P11 token object with: + // - CKA_TOKEN=true, CKA_LABEL=label, CKA_EXTRACTABLE=false + keyStore.setKeyEntry(label, secretKey, null, null); + + logger.info("Generated and imported AES-{} key '{}' into HSM (purpose: {})", + keyBits, label, purpose); + return label; + + } catch (KeyStoreException e) { + handlePKCS11Exception(e, "Failed to import key into HSM KeyStore"); + } catch (NoSuchAlgorithmException e) { + handlePKCS11Exception(e, "SecureRandom algorithm not available or key not retrievable"); + } 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"); + } + } finally { + // Immediately clear sensitive key material from memory + if (keyBytes != null) { + Arrays.fill(keyBytes, (byte) 0); + } + } + return null; // Unreachable } + /** + * 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-GCM for authenticated encryption: + *

    + *
  • Generates a random 96-bit IV
  • + *
  • Encrypts the DEK using the KEK from HSM
  • + *
  • Appends a 128-bit authentication tag
  • + *
  • Returns format: [IV (12 bytes)][ciphertext+tag]
  • + *
+ * + *

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 blob: [IV][ciphertext+tag] + * @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 { - return "wrapped_blob".getBytes(); + if (plainDek == null || plainDek.length == 0) { + throw KMSException.invalidParameter("Plain DEK cannot be null or empty"); + } + + SecretKey kek = null; + try { + kek = getKekFromKeyStore(kekLabel); + + // Generate random IV for GCM + byte[] iv = new byte[GCM_IV_LENGTH]; + new SecureRandom().nextBytes(iv); + + // Create and initialize AES-GCM cipher in ENCRYPT_MODE + Cipher cipher = createGCMCipher(kek, iv, Cipher.ENCRYPT_MODE); + + // Encrypt the plaintext DEK using doFinal (GCM includes authentication tag) + byte[] wrappedBlob = cipher.doFinal(plainDek); + + // Prepend IV to wrapped blob: [IV][ciphertext+tag] + byte[] result = prependIV(iv, wrappedBlob); + + logger.debug("Wrapped key with KEK '{}'", kekLabel); + return result; + } catch (IllegalBlockSizeException e) { + handlePKCS11Exception(e, "Invalid block size for wrapping"); + } catch (Exception e) { + handlePKCS11Exception(e, "Failed to wrap key with HSM"); + } finally { + // Zeroize KEK reference (actual key material is in HSM, but clear reference) + kek = null; + } + return null; // Unreachable } + /** + * 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; // Unreachable + } + + /** + * Prepends IV to data, creating a new byte array. + * + * @param iv Initialization vector + * @param data Data to prepend IV to + * @return Combined array: [IV][data] + */ + private byte[] prependIV(byte[] iv, byte[] data) { + byte[] result = new byte[GCM_IV_LENGTH + data.length]; + System.arraycopy(iv, 0, result, 0, GCM_IV_LENGTH); + System.arraycopy(data, 0, result, GCM_IV_LENGTH, data.length); + return result; + } + + /** + * Creates and initializes an AES-GCM cipher. + * + * @param kek Key Encryption Key + * @param iv Initialization vector + * @param mode Cipher mode (ENCRYPT_MODE or DECRYPT_MODE) + * @return Initialized Cipher instance + * @throws KMSException if cipher creation or initialization fails + */ + private Cipher createGCMCipher(SecretKey kek, byte[] iv, int mode) throws KMSException { + try { + Cipher cipher = Cipher.getInstance(ALGORITHM, provider); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(mode, kek, gcmSpec); + return cipher; + } catch (NoSuchPaddingException e) { + handlePKCS11Exception(e, "GCM padding not supported"); + } catch (InvalidKeyException e) { + handlePKCS11Exception(e, "Invalid KEK"); + } catch (InvalidAlgorithmParameterException e) { + handlePKCS11Exception(e, "Invalid GCM parameters"); + } catch (NoSuchAlgorithmException e) { + handlePKCS11Exception(e, String.format("Algorithm %s not supported.", ALGORITHM)); + } + return null; // Unreachable + } + + /** + * Unwraps (decrypts) a wrapped DEK using a KEK stored in the HSM. + * + *

Process: + *

    + *
  1. Extracts IV from the wrapped blob
  2. + *
  3. Retrieves KEK from HSM using the label
  4. + *
  5. Decrypts using AES-GCM (verifies authentication tag)
  6. + *
  7. Returns plaintext DEK
  8. + *
+ * + *

Security: The returned plaintext DEK must be zeroized by the caller after use. + * + *

Expected format: [IV (12 bytes)][ciphertext+tag] + * + * @param wrappedBlob Wrapped DEK blob (IV + ciphertext + tag) + * @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 (e.g., authentication tag + * verification fails)
  • + *
+ */ byte[] unwrapKey(byte[] wrappedBlob, String kekLabel) throws KMSException { - return new byte[32]; // 256 bits + if (wrappedBlob == null || wrappedBlob.length == 0) { + throw KMSException.invalidParameter("Wrapped blob cannot be null or empty"); + } + + if (wrappedBlob.length < GCM_IV_LENGTH + GCM_TAG_LENGTH) { + throw KMSException.invalidParameter("Wrapped blob too short: expected at least " + + (GCM_IV_LENGTH + GCM_TAG_LENGTH) + " bytes"); + } + + SecretKey kek = null; + try { + kek = getKekFromKeyStore(kekLabel); + + // Extract IV and ciphertext from wrapped blob + IVAndCiphertext extracted = extractIVAndCiphertext(wrappedBlob); + + // Create and initialize AES-GCM cipher in DECRYPT_MODE + Cipher cipher = createGCMCipher(kek, extracted.iv, Cipher.DECRYPT_MODE); + + // Decrypt the ciphertext to get plaintext DEK (GCM verifies authentication tag) + byte[] plainDek = cipher.doFinal(extracted.ciphertextWithTag); + + logger.debug("Unwrapped key with KEK '{}'", kekLabel); + return plainDek; + } catch (BadPaddingException e) { + // GCM authentication tag verification failed + throw KMSException.wrapUnwrapFailed( + "Authentication failed: wrapped key may be corrupted or KEK is incorrect", e); + } catch (IllegalBlockSizeException e) { + handlePKCS11Exception(e, "Invalid block size for unwrapping"); + } catch (Exception e) { + handlePKCS11Exception(e, "Failed to unwrap key with HSM"); + } finally { + // Zeroize KEK reference + kek = null; + } + return null; // Unreachable } + /** + * Extracts IV and ciphertext from a wrapped blob. + * + * @param wrappedBlob Wrapped blob containing IV and ciphertext + * @return IVAndCiphertext containing extracted IV and ciphertext + * @throws KMSException if wrapped blob is too short + */ + private IVAndCiphertext extractIVAndCiphertext(byte[] wrappedBlob) throws KMSException { + if (wrappedBlob.length < GCM_IV_LENGTH + GCM_TAG_LENGTH) { + throw KMSException.invalidParameter("Wrapped blob too short: expected at least " + + (GCM_IV_LENGTH + GCM_TAG_LENGTH) + " bytes"); + } + byte[] iv = new byte[GCM_IV_LENGTH]; + System.arraycopy(wrappedBlob, 0, iv, 0, GCM_IV_LENGTH); + byte[] ciphertextWithTag = new byte[wrappedBlob.length - GCM_IV_LENGTH]; + System.arraycopy(wrappedBlob, GCM_IV_LENGTH, ciphertextWithTag, 0, ciphertextWithTag.length); + return new IVAndCiphertext(iv, ciphertextWithTag); + } + + /** + * 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 { - // Stub + try { + // Check if key exists first + if (!keyStore.containsAlias(label)) { + throw KMSException.kekNotFound("Key with label '" + label + "' not found in HSM"); + } + + // Delete key from KeyStore + 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 { - return true; + try { + // Try to retrieve key from HSM KeyStore + 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; + } + } + + /** + * Helper class to hold IV and ciphertext extracted from wrapped blob. + */ + private static class IVAndCiphertext { + final byte[] iv; + final byte[] ciphertextWithTag; + + IVAndCiphertext(byte[] iv, byte[] ciphertextWithTag) { + this.iv = iv; + this.ciphertextWithTag = ciphertextWithTag; + } } } } 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 index 98fc608d6f8..cdd29d2cf24 100644 --- 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 @@ -16,14 +16,17 @@ specific language governing permissions and limitations under the License. --> - - + + + + diff --git a/plugins/maintenance/src/test/java/org/apache/cloudstack/maintenance/ManagementServerMaintenanceManagerImplTest.java b/plugins/maintenance/src/test/java/org/apache/cloudstack/maintenance/ManagementServerMaintenanceManagerImplTest.java index a208893f6d1..e5bb9156725 100644 --- a/plugins/maintenance/src/test/java/org/apache/cloudstack/maintenance/ManagementServerMaintenanceManagerImplTest.java +++ b/plugins/maintenance/src/test/java/org/apache/cloudstack/maintenance/ManagementServerMaintenanceManagerImplTest.java @@ -81,7 +81,7 @@ public class ManagementServerMaintenanceManagerImplTest { Mockito.doReturn(expectedCount).when(jobManagerMock).countPendingNonPseudoJobs(1L); return expectedCount; } - + @Test public void countPendingJobs() { long expectedCount = prepareCountPendingJobs(); diff --git a/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java b/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java index 16f1a5472bd..958e4f94d34 100644 --- a/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java @@ -111,8 +111,9 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable @Override public KMSProvider getKMSProvider(String name) { + // Default to database provider if no name specified if (StringUtils.isEmpty(name)) { - return getConfiguredKmsProvider(); + name = "database"; } String providerName = name.toLowerCase(); @@ -130,9 +131,9 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable @Override public KMSProvider getKMSProviderForZone(Long zoneId) throws KMSException { - // For now, use global provider - // In the future, could support zone-specific providers via zone-scoped config - return getConfiguredKmsProvider(); + // Default to database provider for backward compatibility + // HSM-based keys will use provider from HSM profile's protocol field + return getKMSProvider("database"); } @Override @@ -225,40 +226,26 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable } } - @Override @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_CREATE, eventDescription = "creating user KMS key") - public KMSKey createUserKMSKey(Long accountId, Long domainId, Long zoneId, - String name, String description, KeyPurpose purpose, - Integer keyBits) throws KMSException { - // Delegate to method with profileId - return createUserKMSKey(accountId, domainId, zoneId, name, description, purpose, keyBits, null); - } - KMSKey createUserKMSKey(Long accountId, Long domainId, Long zoneId, String name, String description, KeyPurpose purpose, - Integer keyBits, String hsmProfileName) throws KMSException { + Integer keyBits, long hsmProfileId) throws KMSException { validateKmsEnabled(zoneId); - KMSProvider provider = getKMSProviderForZone(zoneId); - - // Resolve HSM Profile - Long hsmProfileId = null; - if (hsmProfileName != null) { - HSMProfileVO profile = hsmProfileDao.findByName(hsmProfileName); - if (profile == null) { - throw KMSException.invalidParameter("HSM Profile not found: " + hsmProfileName); - } - // Validate access - if (profile.getAccountId() != null && !profile.getAccountId().equals(accountId)) { - // Check if admin - // For simplicity, strict check for now. Ideally should check if user is admin. - // Assuming caller check happened upstream in createKMSKey(CreateKMSKeyCmd) - } - hsmProfileId = profile.getId(); - } else { - // Auto-resolve based on hierarchy - hsmProfileId = resolveHSMProfile(accountId, zoneId, provider.getProviderName()); + HSMProfileVO profile = hsmProfileDao.findById(hsmProfileId); + if (profile == null) { + throw KMSException.invalidParameter("HSM Profile not found"); } + // Validate access + if (profile.getAccountId() != null && !profile.getAccountId().equals(accountId)) { + // Check if admin + // For simplicity, strict check for now. Ideally should check if user is admin. + // Assuming caller check happened upstream in createKMSKey(CreateKMSKeyCmd) + throw KMSException.invalidParameter("HSM Profile not found"); + } + + // Determine provider from HSM profile or default to database + KMSProvider provider = getKMSProvider(profile.getProtocol()); // Generate unique KEK label String kekLabel = purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); @@ -290,46 +277,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable return kmsKey; } - Long resolveHSMProfile(Long accountId, Long zoneId, String providerName) { - // Only applicable for providers that use profiles (pkcs11, kmip) - if ("database".equalsIgnoreCase(providerName)) { - return null; - } - - // 1. User-provided profile - List userProfiles = hsmProfileDao.listByAccountId(accountId); - if (CollectionUtils.isNotEmpty(userProfiles)) { - // Filter by protocol/provider match if needed, for now pick first enabled - for (HSMProfileVO p : userProfiles) { - if (p.isEnabled() && isProviderMatch(p, providerName)) return p.getId(); - } - } - - // 2. Zone-scoped admin profile - List zoneProfiles = hsmProfileDao.listAdminProfiles(zoneId); - if (CollectionUtils.isNotEmpty(zoneProfiles)) { - for (HSMProfileVO p : zoneProfiles) { - if (p.isEnabled() && isProviderMatch(p, providerName)) return p.getId(); - } - } - - // 3. Global admin profile - List globalProfiles = hsmProfileDao.listAdminProfiles(); - if (CollectionUtils.isNotEmpty(globalProfiles)) { - for (HSMProfileVO p : globalProfiles) { - if (p.isEnabled() && isProviderMatch(p, providerName)) return p.getId(); - } - } - - // If provider is not database, we must have a profile - throw new CloudRuntimeException("No suitable HSM profile found for provider " + providerName + " for account " + accountId); - } - - boolean isProviderMatch(HSMProfileVO profile, String providerName) { - // Simple mapping: PKCS11 -> pkcs11, KMIP -> kmip - return profile.getProtocol().equalsIgnoreCase(providerName); - } - @Override public List listUserKMSKeys(Long accountId, Long domainId, Long zoneId, KeyPurpose purpose, KMSKey.State state) { @@ -490,7 +437,12 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable throw KMSException.invalidParameter("KMS key purpose is not VOLUME_ENCRYPTION: " + kmsKey); } - KMSProvider provider = getKMSProviderForZone(kmsKey.getZoneId()); + HSMProfileVO hsmProfile = hsmProfileDao.findById(kmsKey.getHsmProfileId()); + if (hsmProfile == null) { + throw KMSException.invalidParameter("HSM profile not found: " + kmsKey.getHsmProfileId()); + } + + KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); // Get active KEK version KMSKekVersionVO activeVersion = getActiveKekVersion(kmsKey.getId()); @@ -588,7 +540,8 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable cmd.getName(), cmd.getDescription(), keyPurpose, - bits + bits, + cmd.getHsmProfileId() ); return responseGenerator.createKMSKeyResponse(kmsKey); @@ -823,13 +776,47 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable Long zoneId = cmd.getZoneId(); String accountName = cmd.getAccountName(); Long domainId = cmd.getDomainId(); + Long kmsKeyId = cmd.getKmsKeyId(); if (zoneId == null) { throw KMSException.invalidParameter("zoneId must be specified"); } + if (kmsKeyId == null) { + throw KMSException.invalidParameter("kmsKeyId must be specified"); + } + validateKmsEnabled(zoneId); + // Get and validate KMS key + KMSKeyVO kmsKey = kmsKeyDao.findById(kmsKeyId); + if (kmsKey == null) { + throw KMSException.kekNotFound("KMS key not found: " + kmsKeyId); + } + + if (kmsKey.getState() != KMSKey.State.Enabled) { + throw KMSException.invalidParameter("KMS key is not enabled: " + kmsKey.getUuid()); + } + + if (kmsKey.getPurpose() != KeyPurpose.VOLUME_ENCRYPTION) { + throw KMSException.invalidParameter("KMS key purpose must be VOLUME_ENCRYPTION"); + } + + // Get provider from KMS key's HSM profile + KMSProvider provider; + if (kmsKey.getHsmProfileId() != null) { + HSMProfileVO profile = hsmProfileDao.findById(kmsKey.getHsmProfileId()); + if (profile == null) { + throw KMSException.invalidParameter("HSM Profile not found for KMS key"); + } + provider = getKMSProvider(profile.getProtocol()); + } else { + provider = getKMSProvider("database"); + } + + // Get active KEK version + KMSKekVersionVO activeVersion = getActiveKekVersion(kmsKey.getId()); + Long accountId = null; if (accountName != null) { accountId = accountManager.finalyzeAccountId(accountName, domainId, null, true); @@ -837,9 +824,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable int pageSize = 100; // Process 100 volumes per page to avoid OutOfMemoryError - // Get provider - KMSProvider provider = getKMSProviderForZone(zoneId); - int successCount = 0; int failureCount = 0; logger.info("Starting migration of volumes to KMS (zone: {}, account: {}, domain: {})", @@ -871,41 +855,13 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable // The KMS will store the same format, maintaining compatibility byte[] passphraseBytes = passphrase.getPassphrase(); - // Get or create KMS key for account - KMSKeyVO kmsKey; - List accountKeys = listUserKMSKeys( - volume.getAccountId(), - volume.getDomainId(), - zoneId, - KeyPurpose.VOLUME_ENCRYPTION, - KMSKey.State.Enabled - ); - - if (!accountKeys.isEmpty()) { - kmsKey = (KMSKeyVO) accountKeys.get(0); // Use first available key - } else { - // Create new KMS key for account - String keyName = "Volume-Encryption-Key-" + volume.getAccountId(); - kmsKey = (KMSKeyVO) createUserKMSKey( - volume.getAccountId(), - volume.getDomainId(), - zoneId, - keyName, - "Auto-created for volume migration", - KeyPurpose.VOLUME_ENCRYPTION, - 256 // Default to 256 bits - ); - logger.info("Created KMS key {} for account {} during migration", kmsKey, volume.getAccountId()); - } - - // Get active KEK version - KMSKekVersionVO activeVersion = getActiveKekVersion(kmsKey.getId()); - - // Wrap existing passphrase bytes as DEK (don't generate new DEK) + // Wrap existing passphrase bytes as DEK using the specified KMS key + // Pass the HSM profile ID from the active version WrappedKey wrappedKey = provider.wrapKey( passphraseBytes, KeyPurpose.VOLUME_ENCRYPTION, - activeVersion.getKekLabel() + activeVersion.getKekLabel(), + activeVersion.getHsmProfileId() ); // Store wrapped key @@ -1011,16 +967,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable return KMSException.transientError("KMS operation failed: " + e.getMessage(), e); } - private KMSProvider getConfiguredKmsProvider() { - String providerName = KMSProviderPlugin.value(); - String providerKey = providerName != null ? providerName.toLowerCase() : null; - if (providerKey != null && kmsProviderMap.containsKey(providerKey) && kmsProviderMap.get(providerKey) != null) { - return kmsProviderMap.get(providerKey); - } - - throw new CloudRuntimeException("Failed to find default configured KMS provider plugin: " + providerName); - } - public void setKmsProviders(List kmsProviders) { this.kmsProviders = kmsProviders; initializeKmsProviderMap(); @@ -1055,30 +1001,20 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable super.start(); initializeKmsProviderMap(); - String configuredProviderName = KMSProviderPlugin.value(); - String providerKey = configuredProviderName != null ? configuredProviderName.toLowerCase() : null; - KMSProvider provider = null; - if (providerKey != null && kmsProviderMap.containsKey(providerKey)) { - provider = kmsProviderMap.get(providerKey); - logger.info("Configured KMS provider: {}", provider.getProviderName()); - } - - if (provider == null) { - logger.warn("No valid configured KMS provider found. KMS functionality will be unavailable."); - // Don't fail - KMS is optional - return true; - } - - // Run health check on startup - try { - boolean healthy = provider.healthCheck(); - if (healthy) { - logger.info("KMS provider {} health check passed", provider.getProviderName()); - } else { - logger.warn("KMS provider {} health check failed", provider.getProviderName()); + // Run health check on all registered providers + for (KMSProvider provider : kmsProviderMap.values()) { + if (provider != null) { + try { + boolean healthy = provider.healthCheck(); + if (healthy) { + logger.info("KMS provider {} health check passed", provider.getProviderName()); + } else { + logger.warn("KMS provider {} health check failed", provider.getProviderName()); + } + } catch (Exception e) { + logger.warn("KMS provider {} health check error: {}", provider.getProviderName(), e.getMessage()); + } } - } catch (Exception e) { - logger.warn("KMS provider health check error: {}", e.getMessage()); } // Schedule background rewrap worker @@ -1276,7 +1212,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable @Override public ConfigKey[] getConfigKeys() { return new ConfigKey[]{ - KMSProviderPlugin, KMSEnabled, KMSDekSizeBits, KMSRetryCount, @@ -1296,7 +1231,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable cmdList.add(DeleteKMSKeyCmd.class); cmdList.add(RotateKMSKeyCmd.class); cmdList.add(MigrateVolumesToKMSCmd.class); - cmdList.add(MigrateVolumesToKMSCmd.class); cmdList.add(AddHSMProfileCmd.class); cmdList.add(ListHSMProfilesCmd.class); cmdList.add(UpdateHSMProfileCmd.class); @@ -1359,6 +1293,22 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable List result = new ArrayList<>(); + if (cmd.getId() != null) { + HSMProfileVO key = hsmProfileDao.findById(cmd.getId()); + if (key == null) { + return result; + } + // Validate the caller can list this profile + if (!isAdmin) { + Account caller = CallContext.current().getCallingAccount(); + Account owner = accountManager.getAccount(key.getAccountId()); + + accountManager.checkAccess(caller, null, true, owner); + } + result.add(key); + return result; + } + // 1. User's own profiles result.addAll(hsmProfileDao.listByAccountId(accountId)); diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java index 6ed11a9bcc9..ac029ce7424 100644 --- a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java @@ -66,8 +66,6 @@ public class KMSManagerImplHSMTest { private AccountManager accountManager; private Long testAccountId = 100L; - private Long testZoneId = 1L; - private String testProviderName = "pkcs11"; /** * Test: isSensitiveKey correctly identifies "pin" as sensitive @@ -126,144 +124,6 @@ public class KMSManagerImplHSMTest { assertTrue("'Password' (mixed case) should be detected as sensitive", resultMixed); } - /** - * Test: resolveHSMProfile selects user profile when available - */ - @Test - public void testResolveHSMProfile_SelectsUserProfile() { - // Setup: User has a profile - HSMProfileVO userProfile = mock(HSMProfileVO.class); - when(userProfile.getId()).thenReturn(1L); - when(userProfile.isEnabled()).thenReturn(true); - when(userProfile.getProtocol()).thenReturn(testProviderName); - when(hsmProfileDao.listByAccountId(testAccountId)).thenReturn(Arrays.asList(userProfile)); - - Long result = kmsManager.resolveHSMProfile(testAccountId, testZoneId, testProviderName); - - assertNotNull("Should return user profile ID", result); - assertEquals("Should select user profile", userProfile.getId(), result.longValue()); - verify(hsmProfileDao).listByAccountId(testAccountId); - } - - /** - * Test: resolveHSMProfile falls back to zone admin profile when no user profile - */ - @Test - public void testResolveHSMProfile_FallbackToZoneAdmin() { - // Setup: No user profile, but zone admin profile exists - HSMProfileVO zoneProfile = mock(HSMProfileVO.class); - when(zoneProfile.getId()).thenReturn(2L); - when(zoneProfile.isEnabled()).thenReturn(true); - when(zoneProfile.getProtocol()).thenReturn(testProviderName); - when(hsmProfileDao.listByAccountId(testAccountId)).thenReturn(new ArrayList<>()); - when(hsmProfileDao.listAdminProfiles(testZoneId)).thenReturn(Arrays.asList(zoneProfile)); - - Long result = kmsManager.resolveHSMProfile(testAccountId, testZoneId, testProviderName); - - assertNotNull("Should return zone admin profile ID", result); - assertEquals("Should select zone admin profile", zoneProfile.getId(), result.longValue()); - verify(hsmProfileDao).listByAccountId(testAccountId); - verify(hsmProfileDao).listAdminProfiles(testZoneId); - } - - /** - * Test: resolveHSMProfile falls back to global admin profile when no user or zone profile - */ - @Test - public void testResolveHSMProfile_FallbackToGlobal() { - // Setup: No user or zone profile, but global admin profile exists - HSMProfileVO globalProfile = mock(HSMProfileVO.class); - when(globalProfile.getId()).thenReturn(3L); - when(globalProfile.isEnabled()).thenReturn(true); - when(globalProfile.getProtocol()).thenReturn(testProviderName); - when(hsmProfileDao.listByAccountId(testAccountId)).thenReturn(new ArrayList<>()); - when(hsmProfileDao.listAdminProfiles(testZoneId)).thenReturn(new ArrayList<>()); - when(hsmProfileDao.listAdminProfiles()).thenReturn(Arrays.asList(globalProfile)); - - Long result = kmsManager.resolveHSMProfile(testAccountId, testZoneId, testProviderName); - - assertNotNull("Should return global admin profile ID", result); - assertEquals("Should select global admin profile", globalProfile.getId(), result.longValue()); - verify(hsmProfileDao).listByAccountId(testAccountId); - verify(hsmProfileDao).listAdminProfiles(testZoneId); - verify(hsmProfileDao).listAdminProfiles(); - } - - /** - * Test: resolveHSMProfile throws exception when no profile found - */ - @Test(expected = CloudRuntimeException.class) - public void testResolveHSMProfile_ThrowsExceptionWhenNoneFound() { - // Setup: No profiles at any level - when(hsmProfileDao.listByAccountId(testAccountId)).thenReturn(new ArrayList<>()); - when(hsmProfileDao.listAdminProfiles(testZoneId)).thenReturn(new ArrayList<>()); - when(hsmProfileDao.listAdminProfiles()).thenReturn(new ArrayList<>()); - - kmsManager.resolveHSMProfile(testAccountId, testZoneId, testProviderName); - } - - /** - * Test: resolveHSMProfile skips disabled profiles - */ - @Test - public void testResolveHSMProfile_SkipsDisabledProfiles() { - // Setup: User has disabled profile, zone has enabled profile - HSMProfileVO disabledProfile = mock(HSMProfileVO.class); - when(disabledProfile.isEnabled()).thenReturn(false); - - HSMProfileVO enabledZoneProfile = mock(HSMProfileVO.class); - when(enabledZoneProfile.getId()).thenReturn(5L); - when(enabledZoneProfile.isEnabled()).thenReturn(true); - when(enabledZoneProfile.getProtocol()).thenReturn(testProviderName); - - when(hsmProfileDao.listByAccountId(testAccountId)).thenReturn(Arrays.asList(disabledProfile)); - when(hsmProfileDao.listAdminProfiles(testZoneId)).thenReturn(Arrays.asList(enabledZoneProfile)); - - Long result = kmsManager.resolveHSMProfile(testAccountId, testZoneId, testProviderName); - - assertNotNull("Should return zone profile ID (skip disabled)", result); - assertEquals("Should select zone profile (not disabled user profile)", enabledZoneProfile.getId(), result.longValue()); - } - - /** - * Test: resolveHSMProfile returns null for database provider - */ - @Test - public void testResolveHSMProfile_ReturnsNullForDatabaseProvider() { - Long result = kmsManager.resolveHSMProfile(testAccountId, testZoneId, "database"); - - assertNull("Should return null for database provider", result); - verify(hsmProfileDao, never()).listByAccountId(anyLong()); - } - - /** - * Test: isProviderMatch correctly matches PKCS11 protocol - */ - @Test - public void testIsProviderMatch_MatchesPKCS11() { - HSMProfileVO profile = mock(HSMProfileVO.class); - when(profile.getProtocol()).thenReturn("PKCS11"); - - boolean result = kmsManager.isProviderMatch(profile, "pkcs11"); - - assertTrue("Should match PKCS11 (case-insensitive)", result); - } - - /** - * Test: isProviderMatch is case-insensitive - */ - @Test - public void testIsProviderMatch_MatchesDifferentCases() { - HSMProfileVO profile = mock(HSMProfileVO.class); - when(profile.getProtocol()).thenReturn("pkcs11"); - - boolean resultUpper = kmsManager.isProviderMatch(profile, "PKCS11"); - boolean resultMixed = kmsManager.isProviderMatch(profile, "Pkcs11"); - - assertTrue("Should match PKCS11 (uppercase)", resultUpper); - assertTrue("Should match Pkcs11 (mixed case)", resultMixed); - } - /** * Test: createHSMProfileResponse populates details correctly */ diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java index 9e30d6178ef..c5af2a61f37 100644 --- a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java @@ -111,7 +111,7 @@ public class KMSManagerImplKeyCreationTest { doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); KMSKey result = kmsManager.createUserKMSKey(testAccountId, testDomainId, - testZoneId, "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileName); + testZoneId, "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); // Verify explicit profile was used assertNotNull(result); @@ -125,52 +125,6 @@ public class KMSManagerImplKeyCreationTest { assertEquals(hsmProfileId, createdKey.getHsmProfileId()); } - /** - * Test: createUserKMSKey auto-resolves profile when not provided - */ - @Test - public void testCreateUserKMSKey_AutoResolvesProfile() throws Exception { - // Setup: No explicit profile name, should auto-resolve - Long autoResolvedProfileId = 20L; - - // Mock profile resolution hierarchy - user has a profile - HSMProfileVO userProfile = mock(HSMProfileVO.class); - when(userProfile.getId()).thenReturn(autoResolvedProfileId); - when(userProfile.isEnabled()).thenReturn(true); - when(userProfile.getProtocol()).thenReturn(testProviderName); - when(hsmProfileDao.listByAccountId(testAccountId)).thenReturn(Arrays.asList(userProfile)); - - // Mock provider KEK creation - when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(autoResolvedProfileId))) - .thenReturn("test-kek-label"); - - // Mock DAO persist operations - KMSKeyVO mockKey = mock(KMSKeyVO.class); - when(mockKey.getId()).thenReturn(1L); - when(kmsKeyDao.persist(any(KMSKeyVO.class))).thenReturn(mockKey); - - KMSKekVersionVO mockVersion = mock(KMSKekVersionVO.class); - when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(mockVersion); - - // Mock getKMSProviderForZone - doReturn(kmsProvider).when(kmsManager).getKMSProviderForZone(testZoneId); - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); - - KMSKey result = kmsManager.createUserKMSKey(testAccountId, testDomainId, - testZoneId, "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, null); - - // Verify profile was auto-resolved - assertNotNull(result); - verify(hsmProfileDao).listByAccountId(testAccountId); - verify(kmsProvider).createKek(any(KeyPurpose.class), anyString(), eq(256), eq(autoResolvedProfileId)); - - // Verify KMSKeyVO was created with auto-resolved profile ID - ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(KMSKeyVO.class); - verify(kmsKeyDao).persist(keyCaptor.capture()); - KMSKeyVO createdKey = keyCaptor.getValue(); - assertEquals(autoResolvedProfileId, createdKey.getHsmProfileId()); - } - /** * Test: createUserKMSKey throws exception when explicit profile not found */ @@ -178,59 +132,14 @@ public class KMSManagerImplKeyCreationTest { public void testCreateUserKMSKey_ThrowsExceptionWhenProfileNotFound() throws KMSException { // Setup: Profile name provided but doesn't exist String invalidProfileName = "non-existent-profile"; - when(hsmProfileDao.findByName(invalidProfileName)).thenReturn(null); + long hsmProfileId = 1L; + when(hsmProfileDao.findById(hsmProfileId)).thenReturn(null); doReturn(kmsProvider).when(kmsManager).getKMSProviderForZone(testZoneId); doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); kmsManager.createUserKMSKey(testAccountId, testDomainId, testZoneId, - "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, invalidProfileName); - } - - /** - * Test: createUserKMSKey auto-resolves to zone admin profile when no user profile - */ - @Test - public void testCreateUserKMSKey_AutoResolvesToZoneAdmin() throws Exception { - // Setup: No user profile, but zone admin profile exists - Long zoneAdminProfileId = 30L; - - HSMProfileVO zoneProfile = mock(HSMProfileVO.class); - when(zoneProfile.getId()).thenReturn(zoneAdminProfileId); - when(zoneProfile.isEnabled()).thenReturn(true); - when(zoneProfile.getProtocol()).thenReturn(testProviderName); - - when(hsmProfileDao.listByAccountId(testAccountId)).thenReturn(new ArrayList<>()); - when(hsmProfileDao.listAdminProfiles(testZoneId)).thenReturn(Arrays.asList(zoneProfile)); - - // Mock provider KEK creation - when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(zoneAdminProfileId))) - .thenReturn("test-kek-label"); - - // Mock DAO persist operations - KMSKeyVO mockKey = mock(KMSKeyVO.class); - when(mockKey.getId()).thenReturn(1L); - when(kmsKeyDao.persist(any(KMSKeyVO.class))).thenReturn(mockKey); - - KMSKekVersionVO mockVersion = mock(KMSKekVersionVO.class); - when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(mockVersion); - - doReturn(kmsProvider).when(kmsManager).getKMSProviderForZone(testZoneId); - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); - - KMSKey result = kmsManager.createUserKMSKey(testAccountId, testDomainId, - testZoneId, "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, null); - - // Verify zone admin profile was used - assertNotNull(result); - verify(hsmProfileDao).listByAccountId(testAccountId); - verify(hsmProfileDao).listAdminProfiles(testZoneId); - verify(kmsProvider).createKek(any(KeyPurpose.class), anyString(), eq(256), eq(zoneAdminProfileId)); - - // Verify KMSKeyVO was created with zone admin profile ID - ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(KMSKeyVO.class); - verify(kmsKeyDao).persist(keyCaptor.capture()); - assertEquals(zoneAdminProfileId, keyCaptor.getValue().getHsmProfileId()); + "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); } /** @@ -261,7 +170,7 @@ public class KMSManagerImplKeyCreationTest { doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); kmsManager.createUserKMSKey(testAccountId, testDomainId, testZoneId, - "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, null); + "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); // Verify KEK version was created with correct profile ID ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(KMSKekVersionVO.class); @@ -271,37 +180,4 @@ public class KMSManagerImplKeyCreationTest { assertEquals(Integer.valueOf(1), Integer.valueOf(createdVersion.getVersionNumber())); assertEquals("test-kek-label", createdVersion.getKekLabel()); } - - /** - * Test: createUserKMSKey returns null profile ID for database provider - */ - @Test - public void testCreateUserKMSKey_NullProfileIdForDatabaseProvider() throws Exception { - // Setup: Database provider doesn't use profiles - KMSProvider databaseProvider = mock(KMSProvider.class); - when(databaseProvider.getProviderName()).thenReturn("database"); - when(databaseProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(null))) - .thenReturn("test-kek-label"); - - KMSKeyVO mockKey = mock(KMSKeyVO.class); - when(mockKey.getId()).thenReturn(1L); - when(kmsKeyDao.persist(any(KMSKeyVO.class))).thenReturn(mockKey); - - KMSKekVersionVO mockVersion = mock(KMSKekVersionVO.class); - when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(mockVersion); - - doReturn(databaseProvider).when(kmsManager).getKMSProviderForZone(testZoneId); - doReturn(true).when(kmsManager).isKmsEnabled(testZoneId); - - kmsManager.createUserKMSKey(testAccountId, testDomainId, testZoneId, - "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, null); - - // Verify KEK was created with null profile ID - verify(databaseProvider).createKek(any(KeyPurpose.class), anyString(), eq(256), eq(null)); - - // Verify KMSKeyVO has null profile ID - ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(KMSKeyVO.class); - verify(kmsKeyDao).persist(keyCaptor.capture()); - assertEquals(null, keyCaptor.getValue().getHsmProfileId()); - } } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 513dfcdaa36..ce2b458c104 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1434,6 +1434,8 @@ "label.keyboardtype": "Keyboard type", "label.keypair": "SSH key pair", "label.keypairs": "SSH key pair(s)", +"label.kms.key": "KMS Key", +"label.select.kms.key.optional": "Select KMS Key (optional)", "label.kubeconfig.cluster": "Kubernetes Cluster config", "label.kubernetes": "Kubernetes", "label.kubernetes.access.details": "The kubernetes nodes can be accessed via ssh using:
ssh -i [ssh_key] -p [port_number] cloud@[public_ip_address]

where,
ssh_key: points to the ssh private key file corresponding to the key that was associated while creating the Kubernetes Cluster. If no ssh key was provided during Kubernetes cluster creation, use the ssh private key of the management server.
port_number: can be obtained from the Port Forwarding Tab (Public Port column)", @@ -4224,5 +4226,6 @@ "Compute*Month": "Compute * Month", "GB*Month": "GB * Month", "IP*Month": "IP * Month", -"Policy*Month": "Policy * Month" +"Policy*Month": "Policy * Month", +"message.kms.key.optional": "Optional: Select a KMS key for encryption. If not selected, legacy passphrase encryption will be used." } diff --git a/ui/src/config/router.js b/ui/src/config/router.js index 43e8efd7b5d..ee9520149a5 100644 --- a/ui/src/config/router.js +++ b/ui/src/config/router.js @@ -28,6 +28,7 @@ import compute from '@/config/section/compute' import storage from '@/config/section/storage' import network from '@/config/section/network' import image from '@/config/section/image' +import kms from '@/config/section/kms' import project from '@/config/section/project' import event from '@/config/section/event' import user from '@/config/section/user' @@ -216,6 +217,7 @@ export function asyncRouterMap () { generateRouterMap(compute), generateRouterMap(storage), + generateRouterMap(kms), generateRouterMap(network), generateRouterMap(image), generateRouterMap(event), diff --git a/ui/src/config/section/kms.js b/ui/src/config/section/kms.js new file mode 100644 index 00000000000..340c0e10191 --- /dev/null +++ b/ui/src/config/section/kms.js @@ -0,0 +1,148 @@ +// 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. + +import store from '@/store' + +export default { + name: 'kms', + title: 'label.kms', + icon: 'hdd-outlined', + children: [ + { + name: 'KMS key', + title: 'label.kms.keys', + icon: 'file-text-outlined', + permission: ['listKMSKeys'], + resourceType: 'KMSKey', + columns: () => { + const fields = ['name', 'state', 'account', 'domain', 'purpose'] + return fields + }, + details: ['id', 'name', 'description', 'state', 'account', 'domain', 'created'], + searchFilters: () => { + var filters = ['zoneid'] + if (store.getters.userInfo.roletype === 'Admin') { + filters.push('accountid', 'domainid') + } + return filters + }, + actions: [ + { + api: 'createKMSKey', + icon: 'plus-outlined', + label: 'label.create.kms.key', + listView: true, + popup: true, + dataView: true, + args: (record, store, group) => { + var fields = ['zoneid', 'name', 'description', 'purpose', 'hsmprofileid', 'keybits'] + return (['Admin'].includes(store.userInfo.roletype)) + ? fields.concat(['domainid', 'account']) : fields + } + }, + { + api: 'updateKMSKey', + icon: 'edit-outlined', + docHelp: 'adminguide/storage.html#lifecycle-operations', + label: 'label.update.kms.ket', + dataView: true, + popup: true, + args: ['id', 'name', 'description', 'state'], + mapping: { + id: { + value: (record) => record.id + } + } + }, + { + api: 'deleteKMSKey', + icon: 'delete-outlined', + docHelp: 'adminguide/storage.html#lifecycle-operations', + label: 'label.delete.kms.key', + message: 'message.action.delete.kms.key', + dataView: true, + popup: true, + args: ['id'], + mapping: { + id: { + value: (record) => record.id + } + } + } + ] + }, + { + name: 'hsmprofile', + title: 'label.hsm.profile', + icon: 'file-text-outlined', + permission: ['listHSMProfiles'], + resourceType: 'HSMProfile', + columns: () => { + const fields = ['name', 'state'] + return fields + }, + details: ['id', 'name', 'description', 'state', 'account', 'domain', 'created'], + searchFilters: () => { + var filters = ['zoneid'] + return filters + }, + actions: [ + { + api: 'addHSMProfile', + icon: 'plus-outlined', + label: 'label.create.hsmprofile', + listView: true, + popup: true, + dataView: true, + args: (record, store, group) => { + return (['Admin'].includes(store.userInfo.roletype)) + ? ['zoneid', 'name', 'vendorname', 'domainid', 'accountid', 'details', 'protocol'] : ['zoneid', 'name', 'vendorname', 'details', 'protocol'] + } + }, + { + api: 'updateHSMProfile', + icon: 'edit-outlined', + docHelp: 'adminguide/storage.html#lifecycle-operations', + label: 'label.update.hsm.profile', + dataView: true, + popup: true, + args: ['id', 'name', 'details', 'enabled'], + mapping: { + id: { + value: (record) => record.id + } + } + }, + { + api: 'deleteHSMProfile', + icon: 'delete-outlined', + docHelp: 'adminguide/storage.html#lifecycle-operations', + label: 'label.delete.hsm.profile', + message: 'message.action.delete.hsm.profile', + dataView: true, + popup: true, + args: ['id'], + mapping: { + id: { + value: (record) => record.id + } + } + } + ] + } + ] +} diff --git a/ui/src/views/compute/DeployVM.vue b/ui/src/views/compute/DeployVM.vue index 26176e76005..ca6299b68b5 100644 --- a/ui/src/views/compute/DeployVM.vue +++ b/ui/src/views/compute/DeployVM.vue @@ -341,15 +341,19 @@ @handle-search-filter="($event) => handleSearchFilter('diskOfferings', $event)" > + @update-root-disk-iops-value="updateIOPSValue" + @update-root-kms-key="updateRootKmsKey"/> @@ -394,14 +398,17 @@ @handle-search-filter="($event) => handleSearchFilter('diskOfferings', $event)" > + @update-iops-value="updateIOPSValue" + @update-data-kms-key="updateDataKmsKey"/> @@ -1050,7 +1057,8 @@ export default { keyboards: [], bootTypes: [], bootModes: [], - ioPolicyTypes: [] + ioPolicyTypes: [], + kmsKeys: [] }, rowCount: {}, loading: { @@ -1071,7 +1079,8 @@ export default { pods: false, clusters: false, hosts: false, - groups: false + groups: false, + kmsKeys: false }, owner: { projectid: store.getters.project?.id, @@ -1726,6 +1735,22 @@ export default { serviceOffering (oldValue, newValue) { if (oldValue && newValue && oldValue.id !== newValue.id) { this.dynamicscalingenabled = this.isDynamicallyScalable() + // Fetch KMS keys if encryption is enabled + if (newValue && newValue.encryptroot && this.zoneId) { + this.fetchKmsKeys() + } + } + }, + diskOffering (newValue) { + // Fetch KMS keys if encryption is enabled + if (newValue && newValue.encrypt && this.zoneId) { + this.fetchKmsKeys() + } + }, + overrideDiskOffering (newValue) { + // Fetch KMS keys if encryption is enabled + if (newValue && newValue.encrypt && this.zoneId) { + this.fetchKmsKeys() } }, template (oldValue, newValue) { @@ -1993,6 +2018,25 @@ export default { const param = this.params.networks this.fetchOptions(param, 'networks') }, + fetchKmsKeys () { + if (!this.zoneId) { + return + } + this.loading.kmsKeys = true + this.options.kmsKeys = [] + getAPI('listKMSKeys', { + zoneid: this.zoneId, + account: this.owner.account, + domainid: this.owner.domainid, + projectid: this.owner.projectid + }).then(response => { + this.options.kmsKeys = response.listkmskeysresponse.kmskey || [] + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.loading.kmsKeys = false + }) + }, resetData () { this.vm = { name: null, @@ -2017,6 +2061,12 @@ export default { this.formRef.value.resetFields() this.fetchData() }, + updateRootKmsKey (value) { + this.form.rootkmskeyid = value + }, + updateDataKmsKey (value) { + this.form.datakmskeyid = value + }, updateFieldValue (name, value) { if (name === 'templateid') { this.imageType = 'templateid' @@ -2380,6 +2430,10 @@ export default { deployVmData['details[0].memory'] = values.memory } } + // Add root disk KMS key if selected (optional - falls back to legacy passphrase if not provided) + if (values.rootkmskeyid) { + deployVmData.rootdiskkmskeyid = values.rootkmskeyid + } if (this.selectedTemplateConfiguration) { deployVmData['details[0].configurationId'] = this.selectedTemplateConfiguration.id } @@ -2406,12 +2460,29 @@ export default { }) } } else { - deployVmData.diskofferingid = values.diskofferingid - if (values.size) { - deployVmData.size = values.size + // When a KMS key is selected for data disk, we must use datadisksdetails format + if (values.datakmskeyid) { + deployVmData['datadisksdetails[0].diskofferingid'] = values.diskofferingid + deployVmData['datadisksdetails[0].deviceid'] = 1 // Device ID 1 for first data disk (0=root, 3=CD-ROM reserved) + if (values.size) { + deployVmData['datadisksdetails[0].size'] = values.size + } + deployVmData['datadisksdetails[0].kmskeyid'] = values.datakmskeyid + // Add IOPS if customized + if (this.isCustomizedDiskIOPS) { + deployVmData['datadisksdetails[0].miniops'] = this.diskIOpsMin + deployVmData['datadisksdetails[0].maxiops'] = this.diskIOpsMax + } + } else { + // Legacy format when no KMS key + deployVmData.diskofferingid = values.diskofferingid + if (values.size) { + deployVmData.size = values.size + } } } - if (this.isCustomizedDiskIOPS) { + // IOPS for non-KMS data disks (KMS data disks IOPS handled above in datadisksdetails) + if (this.isCustomizedDiskIOPS && !values.datakmskeyid) { deployVmData['details[0].minIopsDo'] = this.diskIOpsMin deployVmData['details[0].maxIopsDo'] = this.diskIOpsMax } @@ -3087,6 +3158,7 @@ export default { this.selectedBackupOffering = null this.fetchZoneOptions() this.updateZoneAllowsBackupOperations() + this.fetchKmsKeys() }, onSelectPodId (value) { this.podId = value diff --git a/ui/src/views/compute/wizard/DiskSizeSelection.vue b/ui/src/views/compute/wizard/DiskSizeSelection.vue index bd202042e53..43fa556b8ef 100644 --- a/ui/src/views/compute/wizard/DiskSizeSelection.vue +++ b/ui/src/views/compute/wizard/DiskSizeSelection.vue @@ -16,35 +16,62 @@ // under the License.