diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml
index a5e4b07931a..cbc74c65f96 100644
--- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml
+++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml
@@ -315,4 +315,6 @@
+
+
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 59af8f5f6a6..2d479bf0ab3 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
@@ -29,6 +29,7 @@ public class KMSException extends CloudRuntimeException {
* Error types for KMS operations to enable intelligent retry logic
*/
public enum ErrorType {
+ CONNECTION_FAILED(true),
/**
* Provider not initialized or unavailable
*/
diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java
index 0e1c17e7b75..756ab792f92 100644
--- a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java
+++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java
@@ -21,8 +21,6 @@ import org.apache.cloudstack.framework.config.Configurable;
import com.cloud.utils.component.Adapter;
-import java.util.List;
-
/**
* Abstract provider contract for Key Management Service operations.
*
@@ -83,14 +81,6 @@ public interface KMSProvider extends Configurable, Adapter {
*/
void deleteKek(String kekId) throws KMSException;
- /**
- * List all KEK identifiers for a given purpose
- *
- * @param purpose the key purpose to filter by (null = all purposes)
- * @return list of KEK identifiers
- * @throws KMSException if listing fails
- */
- List listKeks(KeyPurpose purpose) throws KMSException;
/**
* Check if a KEK exists and is accessible
diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java
index a7dce2a0dba..be7cf069d91 100644
--- a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java
+++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java
@@ -183,31 +183,6 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider {
}
}
- @Override
- public List listKeks(KeyPurpose purpose) throws KMSException {
- try {
- List keks = new ArrayList<>();
-
- List kekObjects;
- if (purpose != null) {
- kekObjects = kekObjectDao.listByPurpose(purpose);
- } else {
- kekObjects = kekObjectDao.listAll();
- }
-
- for (KMSDatabaseKekObjectVO kekObject : kekObjects) {
- if (kekObject.getRemoved() == null) {
- keks.add(kekObject.getLabel());
- }
- }
-
- logger.debug("listKeks called for purpose: {}. Found {} KEKs.", purpose, keks.size());
- return keks;
- } catch (Exception e) {
- throw KMSException.kekOperationFailed("Failed to list KEKs: " + e.getMessage(), e);
- }
- }
-
@Override
public boolean isKekAvailable(String kekId) throws KMSException {
try {
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 306a6453d26..2b6d557080a 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
@@ -54,19 +54,19 @@ import com.cloud.utils.crypt.DBEncryptionUtil;
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;
// Session pool per HSM profile
private final Map sessionPools = new ConcurrentHashMap<>();
-
+
// Profile configuration caching
private final Map> profileConfigCache = new ConcurrentHashMap<>();
@@ -80,6 +80,16 @@ 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];
@@ -90,7 +100,7 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
if (hsmProfileId == null) {
throw KMSException.invalidParameter("HSM Profile ID is required for PKCS#11 provider");
}
-
+
if (StringUtils.isEmpty(label)) {
label = generateKekLabel(purpose);
}
@@ -142,14 +152,14 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
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
-
+
try {
// 2. Wrap with new KEK
Long profileId = targetHsmProfileId;
if (profileId == null) {
profileId = resolveProfileId(newKekLabel);
}
-
+
return wrapKey(plainKey, oldWrappedKey.getPurpose(), newKekLabel, profileId);
} finally {
// Zeroize plaintext key
@@ -183,16 +193,11 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
}
}
- @Override
- public List listKeks(KeyPurpose purpose) throws KMSException {
- throw new KMSException(KMSException.ErrorType.OPERATION_FAILED, "Listing KEKs directly from HSMs not supported, use DB");
- }
-
@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 {
@@ -210,7 +215,7 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
return true;
}
- private Long resolveProfileId(String kekLabel) throws KMSException {
+ Long resolveProfileId(String kekLabel) throws KMSException {
KMSKekVersionVO version = kmsKekVersionDao.findByKekLabel(kekLabel);
if (version != null && version.getHsmProfileId() != null) {
return version.getHsmProfileId();
@@ -218,12 +223,12 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
throw new KMSException(KMSException.ErrorType.KEK_NOT_FOUND, "Could not resolve HSM profile for KEK: " + kekLabel);
}
- private HSMSessionPool getSessionPool(Long profileId) {
- return sessionPools.computeIfAbsent(profileId,
+ HSMSessionPool getSessionPool(Long profileId) {
+ return sessionPools.computeIfAbsent(profileId,
id -> new HSMSessionPool(id, loadProfileConfig(id)));
}
- private Map loadProfileConfig(Long profileId) {
+ Map loadProfileConfig(Long profileId) {
return profileConfigCache.computeIfAbsent(profileId, id -> {
List details = hsmProfileDetailsDao.listByProfileId(id);
Map config = new HashMap<>();
@@ -238,14 +243,14 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
});
}
- private boolean isSensitiveKey(String key) {
- return key.equalsIgnoreCase("pin") ||
- key.equalsIgnoreCase("password") ||
+ boolean isSensitiveKey(String key) {
+ return key.equalsIgnoreCase("pin") ||
+ key.equalsIgnoreCase("password") ||
key.toLowerCase().contains("secret") ||
key.equalsIgnoreCase("private_key");
}
- private String generateKekLabel(KeyPurpose purpose) {
+ String generateKekLabel(KeyPurpose purpose) {
return purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8);
}
@@ -256,14 +261,14 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
private final Map config;
private final int maxSessions;
private final int minIdleSessions;
-
+
HSMSessionPool(Long profileId, Map config) {
this.profileId = profileId;
this.config = config;
this.maxSessions = Integer.parseInt(config.getOrDefault("max_sessions", "10"));
this.minIdleSessions = Integer.parseInt(config.getOrDefault("min_idle_sessions", "2"));
this.availableSessions = new ArrayBlockingQueue<>(maxSessions);
-
+
// Pre-warm
for (int i = 0; i < minIdleSessions; i++) {
try {
@@ -273,7 +278,7 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
}
}
}
-
+
PKCS11Session acquireSession(long timeoutMs) throws KMSException {
try {
PKCS11Session session = availableSessions.poll();
@@ -288,7 +293,7 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, "Failed to acquire HSM session", e);
}
}
-
+
void releaseSession(PKCS11Session session) {
if (session != null && session.isValid()) {
if (!availableSessions.offer(session)) {
@@ -296,7 +301,7 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
}
}
}
-
+
private PKCS11Session createNewSession() throws KMSException {
return new PKCS11Session(config);
}
@@ -307,12 +312,12 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
private final Map config;
private KeyStore keyStore;
private Provider provider;
-
+
PKCS11Session(Map config) throws KMSException {
this.config = config;
connect();
}
-
+
private void connect() throws KMSException {
try {
String libraryPath = config.get("library_path");
@@ -324,33 +329,33 @@ public class PKCS11HSMProvider extends AdapterBase implements KMSProvider {
throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, "Failed to connect to HSM: " + e.getMessage(), e);
}
}
-
+
boolean isValid() {
return true;
}
-
+
void close() {
if (provider != null) {
Security.removeProvider(provider.getName());
}
}
-
+
String generateKey(String label, int keyBits, KeyPurpose purpose) throws KMSException {
return label;
}
-
+
byte[] wrapKey(byte[] plainDek, String kekLabel) throws KMSException {
return "wrapped_blob".getBytes();
}
-
+
byte[] unwrapKey(byte[] wrappedBlob, String kekLabel) throws KMSException {
return new byte[32]; // 256 bits
}
-
+
void deleteKey(String label) throws KMSException {
// Stub
}
-
+
boolean checkKeyExists(String label) throws KMSException {
return true;
}
diff --git a/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java b/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java
new file mode 100644
index 00000000000..405fe3899be
--- /dev/null
+++ b/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java
@@ -0,0 +1,287 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.cloudstack.kms.provider.pkcs11;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Map;
+
+import org.apache.cloudstack.framework.kms.KMSException;
+import org.apache.cloudstack.framework.kms.KeyPurpose;
+import org.apache.cloudstack.kms.HSMProfileDetailsVO;
+import org.apache.cloudstack.kms.KMSKekVersionVO;
+import org.apache.cloudstack.kms.dao.HSMProfileDetailsDao;
+import org.apache.cloudstack.kms.dao.KMSKekVersionDao;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+
+/**
+ * Unit tests for PKCS11HSMProvider
+ * Tests provider-specific logic: config loading, profile resolution, sensitive key detection
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class PKCS11HSMProviderTest {
+
+ @Spy
+ @InjectMocks
+ private PKCS11HSMProvider provider;
+
+ @Mock
+ private HSMProfileDetailsDao hsmProfileDetailsDao;
+
+ @Mock
+ private KMSKekVersionDao kmsKekVersionDao;
+
+ private Long testProfileId = 1L;
+ private String testKekLabel = "test-kek-label";
+
+ @Before
+ public void setUp() {
+ // Minimal setup
+ }
+
+ /**
+ * Test: resolveProfileId successfully finds profile from KEK label
+ */
+ @Test
+ public void testResolveProfileId_FindsFromKekLabel() throws KMSException {
+ // Setup: KEK version with profile ID
+ KMSKekVersionVO kekVersion = mock(KMSKekVersionVO.class);
+ when(kekVersion.getHsmProfileId()).thenReturn(testProfileId);
+ when(kmsKekVersionDao.findByKekLabel(testKekLabel)).thenReturn(kekVersion);
+
+ // Test
+ Long result = provider.resolveProfileId(testKekLabel);
+
+ // Verify
+ assertNotNull("Should return profile ID", result);
+ assertEquals("Should return correct profile ID", testProfileId, result);
+ verify(kmsKekVersionDao).findByKekLabel(testKekLabel);
+ }
+
+ /**
+ * Test: resolveProfileId throws exception when KEK version not found
+ */
+ @Test(expected = KMSException.class)
+ public void testResolveProfileId_ThrowsExceptionWhenVersionNotFound() throws KMSException {
+ // Setup: No KEK version found
+ when(kmsKekVersionDao.findByKekLabel(testKekLabel)).thenReturn(null);
+
+ // Test - should throw exception
+ provider.resolveProfileId(testKekLabel);
+ }
+
+ /**
+ * Test: resolveProfileId throws exception when profile ID is null
+ */
+ @Test(expected = KMSException.class)
+ public void testResolveProfileId_ThrowsExceptionWhenProfileIdNull() throws KMSException {
+ // Setup: KEK version exists but has null profile ID
+ KMSKekVersionVO kekVersion = mock(KMSKekVersionVO.class);
+ when(kekVersion.getHsmProfileId()).thenReturn(null);
+ when(kmsKekVersionDao.findByKekLabel(testKekLabel)).thenReturn(kekVersion);
+
+ // Test - should throw exception
+ provider.resolveProfileId(testKekLabel);
+ }
+
+ /**
+ * Test: loadProfileConfig loads and decrypts sensitive values
+ */
+ @Test
+ public void testLoadProfileConfig_DecryptsSensitiveValues() {
+ // Setup: Profile details with encrypted pin
+ HSMProfileDetailsVO detail1 = mock(HSMProfileDetailsVO.class);
+ when(detail1.getName()).thenReturn("library_path");
+ when(detail1.getValue()).thenReturn("/path/to/lib.so");
+
+ HSMProfileDetailsVO detail2 = mock(HSMProfileDetailsVO.class);
+ when(detail2.getName()).thenReturn("pin");
+ when(detail2.getValue()).thenReturn("ENC(encrypted_pin)");
+
+ HSMProfileDetailsVO detail3 = mock(HSMProfileDetailsVO.class);
+ when(detail3.getName()).thenReturn("slot_id");
+ when(detail3.getValue()).thenReturn("0");
+
+ when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(
+ Arrays.asList(detail1, detail2, detail3));
+
+ // Test
+ Map config = provider.loadProfileConfig(testProfileId);
+
+ // Verify
+ assertNotNull("Config should not be null", config);
+ assertEquals(3, config.size());
+ assertEquals("/path/to/lib.so", config.get("library_path"));
+ // Note: In real code, DBEncryptionUtil.decrypt would be called
+ // Here we just verify the structure is correct
+ assertTrue("Config should contain pin", config.containsKey("pin"));
+ assertEquals("0", config.get("slot_id"));
+
+ verify(hsmProfileDetailsDao).listByProfileId(testProfileId);
+ }
+
+ /**
+ * Test: loadProfileConfig handles empty details
+ */
+ @Test
+ public void testLoadProfileConfig_HandlesEmptyDetails() {
+ // Setup
+ when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(Arrays.asList());
+
+ // Test
+ Map config = provider.loadProfileConfig(testProfileId);
+
+ // Verify
+ assertNotNull("Config should not be null", config);
+ assertEquals(0, config.size());
+ }
+
+ /**
+ * Test: isSensitiveKey correctly identifies sensitive keys
+ */
+ @Test
+ public void testIsSensitiveKey_IdentifiesSensitiveKeys() {
+ // Test
+ assertTrue(provider.isSensitiveKey("pin"));
+ assertTrue(provider.isSensitiveKey("password"));
+ assertTrue(provider.isSensitiveKey("api_secret"));
+ assertTrue(provider.isSensitiveKey("private_key"));
+ assertTrue(provider.isSensitiveKey("PIN")); // Case-insensitive
+ }
+
+ /**
+ * Test: isSensitiveKey correctly identifies non-sensitive keys
+ */
+ @Test
+ public void testIsSensitiveKey_IdentifiesNonSensitiveKeys() {
+ // Test
+ assertFalse(provider.isSensitiveKey("library_path"));
+ assertFalse(provider.isSensitiveKey("slot_id"));
+ assertFalse(provider.isSensitiveKey("endpoint"));
+ assertFalse(provider.isSensitiveKey("max_sessions"));
+ }
+
+ /**
+ * Test: generateKekLabel creates valid label
+ */
+ @Test
+ public void testGenerateKekLabel_CreatesValidLabel() {
+ // Test
+ String label = provider.generateKekLabel(KeyPurpose.VOLUME_ENCRYPTION);
+
+ // Verify
+ assertNotNull("Label should not be null", label);
+ assertTrue("Label should start with purpose", label.startsWith(KeyPurpose.VOLUME_ENCRYPTION.getName()));
+ assertTrue("Label should contain UUID", label.length() > (KeyPurpose.VOLUME_ENCRYPTION.getName() + "-kek-").length());
+ }
+
+ /**
+ * Test: getProviderName returns correct name
+ */
+ @Test
+ public void testGetProviderName() {
+ assertEquals("pkcs11", provider.getProviderName());
+ }
+
+ /**
+ * Test: createKek requires hsmProfileId
+ */
+ @Test(expected = KMSException.class)
+ public void testCreateKek_RequiresProfileId() throws KMSException {
+ provider.createKek(
+ KeyPurpose.VOLUME_ENCRYPTION,
+ "test-label",
+ 256,
+ null // null profile ID should throw exception
+ );
+ }
+
+ /**
+ * Test: loadProfileConfig caches configuration
+ */
+ @Test
+ public void testLoadProfileConfig_CachesConfiguration() {
+ // Setup
+ HSMProfileDetailsVO detail = mock(HSMProfileDetailsVO.class);
+ when(detail.getName()).thenReturn("library_path");
+ when(detail.getValue()).thenReturn("/path/to/lib.so");
+ when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(Arrays.asList(detail));
+
+ // Load twice
+ provider.loadProfileConfig(testProfileId);
+ provider.loadProfileConfig(testProfileId);
+
+ // DAO should only be called once due to caching
+ verify(hsmProfileDetailsDao, times(1)).listByProfileId(testProfileId);
+ }
+
+ /**
+ * Test: getSessionPool creates pool for new profile
+ */
+ @Test
+ public void testGetSessionPool_CreatesPoolForNewProfile() {
+ // Setup
+ HSMProfileDetailsVO detail = mock(HSMProfileDetailsVO.class);
+ when(detail.getName()).thenReturn("library_path");
+ when(detail.getValue()).thenReturn("/path/to/lib.so");
+ when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(Arrays.asList(detail));
+
+ // Test
+ Object pool = provider.getSessionPool(testProfileId);
+
+ // Verify
+ assertNotNull("Pool should be created", pool);
+ verify(hsmProfileDetailsDao).listByProfileId(testProfileId);
+ }
+
+ /**
+ * Test: getSessionPool reuses pool for same profile
+ */
+ @Test
+ public void testGetSessionPool_ReusesPoolForSameProfile() {
+ // Setup
+ HSMProfileDetailsVO detail = mock(HSMProfileDetailsVO.class);
+ when(detail.getName()).thenReturn("library_path");
+ when(detail.getValue()).thenReturn("/path/to/lib.so");
+ when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(Arrays.asList(detail));
+
+ // Test
+ Object pool1 = provider.getSessionPool(testProfileId);
+ Object pool2 = provider.getSessionPool(testProfileId);
+
+ // Verify
+ assertNotNull("Pool should be created", pool1);
+ assertEquals("Should reuse same pool", pool1, pool2);
+ // Config should only be loaded once
+ verify(hsmProfileDetailsDao, times(1)).listByProfileId(testProfileId);
+ }
+}
diff --git a/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java b/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java
index 0e230420d70..16f1a5472bd 100644
--- a/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java
+++ b/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java
@@ -81,7 +81,6 @@ import java.util.stream.Collectors;
public class KMSManagerImpl extends ManagerBase implements KMSManager, PluggableService {
private static final Logger logger = LogManager.getLogger(KMSManagerImpl.class);
private static final Map kmsProviderMap = new HashMap<>();
- private static KMSProvider configuredKmsProvider;
@Inject
private KMSWrappedKeyDao kmsWrappedKeyDao;
@Inject
@@ -147,7 +146,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
/**
* Internal method to rotate a KEK (create new version and update KMS key state)
*/
- private String rotateKek(Long zoneId, KeyPurpose purpose, String oldKekLabel,
+ String rotateKek(Long zoneId, KeyPurpose purpose, String oldKekLabel,
String newKekLabel, int keyBits, Long newProfileId) throws KMSException {
validateKmsEnabled(zoneId);
@@ -235,7 +234,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
return createUserKMSKey(accountId, domainId, zoneId, name, description, purpose, keyBits, null);
}
- private KMSKey createUserKMSKey(Long accountId, Long domainId, Long zoneId,
+ KMSKey createUserKMSKey(Long accountId, Long domainId, Long zoneId,
String name, String description, KeyPurpose purpose,
Integer keyBits, String hsmProfileName) throws KMSException {
validateKmsEnabled(zoneId);
@@ -291,7 +290,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
return kmsKey;
}
- private Long resolveHSMProfile(Long accountId, Long zoneId, String providerName) {
+ Long resolveHSMProfile(Long accountId, Long zoneId, String providerName) {
// Only applicable for providers that use profiles (pkcs11, kmip)
if ("database".equalsIgnoreCase(providerName)) {
return null;
@@ -326,7 +325,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
throw new CloudRuntimeException("No suitable HSM profile found for provider " + providerName + " for account " + accountId);
}
- private boolean isProviderMatch(HSMProfileVO profile, String providerName) {
+ boolean isProviderMatch(HSMProfileVO profile, String providerName) {
// Simple mapping: PKCS11 -> pkcs11, KMIP -> kmip
return profile.getProtocol().equalsIgnoreCase(providerName);
}
@@ -790,7 +789,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
* @param newVersion the new KEK version to wrap with
* @param provider the KMS provider
*/
- private void rewrapSingleKey(KMSWrappedKeyVO wrappedKeyVO, KMSKeyVO kmsKey,
+ void rewrapSingleKey(KMSWrappedKeyVO wrappedKeyVO, KMSKeyVO kmsKey,
KMSKekVersionVO newVersion, KMSProvider provider) {
byte[] dek = null;
try {
@@ -1013,15 +1012,10 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
}
private KMSProvider getConfiguredKmsProvider() {
- if (configuredKmsProvider != null) {
- return configuredKmsProvider;
- }
-
String providerName = KMSProviderPlugin.value();
String providerKey = providerName != null ? providerName.toLowerCase() : null;
if (providerKey != null && kmsProviderMap.containsKey(providerKey) && kmsProviderMap.get(providerKey) != null) {
- configuredKmsProvider = kmsProviderMap.get(providerKey);
- return configuredKmsProvider;
+ return kmsProviderMap.get(providerKey);
}
throw new CloudRuntimeException("Failed to find default configured KMS provider plugin: " + providerName);
@@ -1063,12 +1057,13 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
String configuredProviderName = KMSProviderPlugin.value();
String providerKey = configuredProviderName != null ? configuredProviderName.toLowerCase() : null;
+ KMSProvider provider = null;
if (providerKey != null && kmsProviderMap.containsKey(providerKey)) {
- configuredKmsProvider = kmsProviderMap.get(providerKey);
- logger.info("Configured KMS provider: {}", configuredKmsProvider.getProviderName());
+ provider = kmsProviderMap.get(providerKey);
+ logger.info("Configured KMS provider: {}", provider.getProviderName());
}
- if (configuredKmsProvider == null) {
+ if (provider == null) {
logger.warn("No valid configured KMS provider found. KMS functionality will be unavailable.");
// Don't fail - KMS is optional
return true;
@@ -1076,11 +1071,11 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
// Run health check on startup
try {
- boolean healthy = configuredKmsProvider.healthCheck();
+ boolean healthy = provider.healthCheck();
if (healthy) {
- logger.info("KMS provider {} health check passed", configuredKmsProvider.getProviderName());
+ logger.info("KMS provider {} health check passed", provider.getProviderName());
} else {
- logger.warn("KMS provider {} health check failed", configuredKmsProvider.getProviderName());
+ logger.warn("KMS provider {} health check failed", provider.getProviderName());
}
} catch (Exception e) {
logger.warn("KMS provider health check error: {}", e.getMessage());
@@ -1301,6 +1296,11 @@ 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);
+ cmdList.add(DeleteHSMProfileCmd.class);
return cmdList;
}
@@ -1314,7 +1314,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
if (StringUtils.isEmpty(protocol)) {
throw KMSException.invalidParameter("Protocol cannot be empty");
}
-
+
// Ensure provider exists for protocol
try {
getKMSProvider(protocol);
@@ -1330,25 +1330,25 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
cmd.getZoneId(),
cmd.getVendorName()
);
-
+
// Persist profile
profile = hsmProfileDao.persist(profile);
-
+
// Persist details
if (cmd.getDetails() != null) {
for (Map.Entry entry : cmd.getDetails().entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
-
+
// Encrypt sensitive values
if (isSensitiveKey(key)) {
value = DBEncryptionUtil.encrypt(value);
}
-
+
hsmProfileDetailsDao.persist(profile.getId(), key, value);
}
}
-
+
return profile;
}
@@ -1356,12 +1356,12 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
public List listHSMProfiles(ListHSMProfilesCmd cmd) {
Long accountId = CallContext.current().getCallingAccount().getId();
boolean isAdmin = accountManager.isAdmin(accountId);
-
+
List result = new ArrayList<>();
-
+
// 1. User's own profiles
result.addAll(hsmProfileDao.listByAccountId(accountId));
-
+
// 2. Admin provided profiles (global and zone-scoped)
// If cmd filters by zone, use it. Else return all relevant ones.
if (cmd.getZoneId() != null) {
@@ -1373,7 +1373,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
// How to list all zone-specific ones? listAdminProfiles() only gets globals?
// Need a way to get all. For now simplified.
}
-
+
// Apply memory filtering for protocol and enabled status
return result.stream()
.filter(p -> cmd.getProtocol() == null || p.getProtocol().equalsIgnoreCase(cmd.getProtocol()))
@@ -1387,19 +1387,19 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
if (profile == null) {
throw KMSException.invalidParameter("HSM Profile not found");
}
-
+
// Check permissions (handled by BaseCmd entity owner usually, but double check)
Account caller = CallContext.current().getCallingAccount();
// Permission check logic here...
-
+
// Check if in use by any KEK versions
// Need a method in kmsKekVersionDao to count by profile ID
// Assuming such logic exists or added:
// if (kmsKekVersionDao.countByProfileId(profile.getId()) > 0) { ... }
-
+
// Delete details
hsmProfileDetailsDao.deleteDetails(profile.getId());
-
+
// Delete profile
return hsmProfileDao.remove(profile.getId());
}
@@ -1410,31 +1410,31 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
if (profile == null) {
throw KMSException.invalidParameter("HSM Profile not found");
}
-
+
if (cmd.getName() != null) {
profile.setName(cmd.getName());
}
if (cmd.getEnabled() != null) {
profile.setEnabled(cmd.getEnabled());
}
-
+
hsmProfileDao.update(profile.getId(), profile);
-
+
if (cmd.getDetails() != null) {
for (Map.Entry entry : cmd.getDetails().entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
-
+
// If sensitive, check if it's already encrypted (starts with ENC()) or needs encryption
- // Assuming client sends plaintext for updates usually.
+ // Assuming client sends plaintext for updates usually.
// Or if they send back the encrypted string from a previous list response, we should detect and keep it.
// Simple heuristic: if isSensitiveKey and doesn't look encrypted (DBEncryptionUtil logic), encrypt it.
- // For now, simpler: always encrypt new sensitive values.
-
+ // For now, simpler: always encrypt new sensitive values.
+
if (isSensitiveKey(key)) {
value = DBEncryptionUtil.encrypt(value);
}
-
+
HSMProfileDetailsVO detail = hsmProfileDetailsDao.findDetail(profile.getId(), key);
if (detail != null) {
detail.setValue(value);
@@ -1444,7 +1444,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
}
}
}
-
+
return profile;
}
@@ -1457,7 +1457,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
response.setVendorName(profile.getVendorName());
response.setEnabled(profile.isEnabled());
response.setCreated(profile.getCreated());
-
+
if (profile.getAccountId() != null) {
Account account = accountManager.getAccount(profile.getAccountId());
if (account != null) {
@@ -1465,7 +1465,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
response.setAccountName(account.getAccountName());
}
}
-
+
// Populate details
List details = hsmProfileDetailsDao.listByProfileId(profile.getId());
Map detailsMap = new HashMap<>();
@@ -1473,14 +1473,14 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable
detailsMap.put(detail.getName(), detail.getValue()); // Return encrypted values as-is
}
response.setDetails(detailsMap);
-
+
return response;
}
- private boolean isSensitiveKey(String key) {
+ boolean isSensitiveKey(String key) {
// List of keys known to be sensitive
- return key.equalsIgnoreCase("pin") ||
- key.equalsIgnoreCase("password") ||
+ return key.equalsIgnoreCase("pin") ||
+ key.equalsIgnoreCase("password") ||
key.toLowerCase().contains("secret") ||
key.equalsIgnoreCase("private_key");
}
diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java
new file mode 100644
index 00000000000..6ed11a9bcc9
--- /dev/null
+++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java
@@ -0,0 +1,305 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.cloudstack.kms;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+
+import org.apache.cloudstack.api.response.HSMProfileResponse;
+import org.apache.cloudstack.kms.dao.HSMProfileDao;
+import org.apache.cloudstack.kms.dao.HSMProfileDetailsDao;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import com.cloud.exception.PermissionDeniedException;
+import com.cloud.user.AccountManager;
+import com.cloud.utils.exception.CloudRuntimeException;
+
+/**
+ * Unit tests for HSM-related business logic in KMSManagerImpl
+ * Tests sensitive key detection, profile resolution hierarchy, and provider matching
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class KMSManagerImplHSMTest {
+
+ @Spy
+ @InjectMocks
+ private KMSManagerImpl kmsManager;
+
+ @Mock
+ private HSMProfileDao hsmProfileDao;
+
+ @Mock
+ private HSMProfileDetailsDao hsmProfileDetailsDao;
+
+ @Mock
+ private AccountManager accountManager;
+
+ private Long testAccountId = 100L;
+ private Long testZoneId = 1L;
+ private String testProviderName = "pkcs11";
+
+ /**
+ * Test: isSensitiveKey correctly identifies "pin" as sensitive
+ */
+ @Test
+ public void testIsSensitiveKey_DetectsPin() {
+ boolean result = kmsManager.isSensitiveKey("pin");
+ assertTrue("'pin' should be detected as sensitive", result);
+ }
+
+ /**
+ * Test: isSensitiveKey correctly identifies "password" as sensitive
+ */
+ @Test
+ public void testIsSensitiveKey_DetectsPassword() {
+ boolean result = kmsManager.isSensitiveKey("password");
+ assertTrue("'password' should be detected as sensitive", result);
+ }
+
+ /**
+ * Test: isSensitiveKey correctly identifies keys containing "secret" as sensitive
+ */
+ @Test
+ public void testIsSensitiveKey_DetectsSecret() {
+ boolean result = kmsManager.isSensitiveKey("api_secret");
+ assertTrue("'api_secret' should be detected as sensitive", result);
+ }
+
+ /**
+ * Test: isSensitiveKey correctly identifies "private_key" as sensitive
+ */
+ @Test
+ public void testIsSensitiveKey_DetectsPrivateKey() {
+ boolean result = kmsManager.isSensitiveKey("private_key");
+ assertTrue("'private_key' should be detected as sensitive", result);
+ }
+
+ /**
+ * Test: isSensitiveKey correctly identifies non-sensitive keys
+ */
+ @Test
+ public void testIsSensitiveKey_DoesNotDetectNonSensitive() {
+ boolean result = kmsManager.isSensitiveKey("library_path");
+ assertFalse("'library_path' should not be detected as sensitive", result);
+ }
+
+ /**
+ * Test: isSensitiveKey is case-insensitive
+ */
+ @Test
+ public void testIsSensitiveKey_CaseInsensitive() {
+ boolean resultUpper = kmsManager.isSensitiveKey("PIN");
+ boolean resultMixed = kmsManager.isSensitiveKey("Password");
+
+ assertTrue("'PIN' (uppercase) should be detected as sensitive", resultUpper);
+ 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
+ */
+ @Test
+ public void testCreateHSMProfileResponse_PopulatesDetails() {
+ Long profileId = 10L;
+
+ HSMProfileVO profile = mock(HSMProfileVO.class);
+ when(profile.getId()).thenReturn(profileId);
+ when(profile.getUuid()).thenReturn("profile-uuid");
+ when(profile.getName()).thenReturn("test-profile");
+ when(profile.getProtocol()).thenReturn("PKCS11");
+ when(profile.getAccountId()).thenReturn(testAccountId);
+ when(profile.getVendorName()).thenReturn("TestVendor");
+ when(profile.isEnabled()).thenReturn(true);
+ when(profile.getCreated()).thenReturn(new java.util.Date());
+
+ HSMProfileDetailsVO detail1 = mock(HSMProfileDetailsVO.class);
+ when(detail1.getName()).thenReturn("library_path");
+ when(detail1.getValue()).thenReturn("/path/to/lib.so");
+
+ HSMProfileDetailsVO detail2 = mock(HSMProfileDetailsVO.class);
+ when(detail2.getName()).thenReturn("pin");
+ when(detail2.getValue()).thenReturn("ENC(encrypted_value)");
+
+ when(hsmProfileDetailsDao.listByProfileId(profileId)).thenReturn(Arrays.asList(detail1, detail2));
+
+ com.cloud.user.Account mockAccount = mock(com.cloud.user.Account.class);
+ when(mockAccount.getUuid()).thenReturn("account-uuid");
+ when(mockAccount.getAccountName()).thenReturn("testaccount");
+ when(accountManager.getAccount(testAccountId)).thenReturn(mockAccount);
+
+ HSMProfileResponse response = kmsManager.createHSMProfileResponse(profile);
+
+ assertNotNull("Response should not be null", response);
+ verify(accountManager).getAccount(testAccountId);
+ verify(hsmProfileDetailsDao).listByProfileId(profileId);
+ }
+}
diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java
new file mode 100644
index 00000000000..9e30d6178ef
--- /dev/null
+++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java
@@ -0,0 +1,307 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.cloudstack.kms;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.ArrayList;
+
+import org.apache.cloudstack.framework.kms.KMSException;
+import org.apache.cloudstack.framework.kms.KMSProvider;
+import org.apache.cloudstack.framework.kms.KeyPurpose;
+import org.apache.cloudstack.kms.dao.HSMProfileDao;
+import org.apache.cloudstack.kms.dao.KMSKekVersionDao;
+import org.apache.cloudstack.kms.dao.KMSKeyDao;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+
+/**
+ * Unit tests for KMS key creation logic in KMSManagerImpl
+ * Tests key creation with explicit and auto-resolved HSM profiles
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class KMSManagerImplKeyCreationTest {
+
+ @Spy
+ @InjectMocks
+ private KMSManagerImpl kmsManager;
+
+ @Mock
+ private KMSKeyDao kmsKeyDao;
+
+ @Mock
+ private KMSKekVersionDao kmsKekVersionDao;
+
+ @Mock
+ private HSMProfileDao hsmProfileDao;
+
+ @Mock
+ private KMSProvider kmsProvider;
+
+ private Long testAccountId = 100L;
+ private Long testDomainId = 1L;
+ private Long testZoneId = 1L;
+ private String testProviderName = "pkcs11";
+
+ @Before
+ public void setUp() {
+ // Setup provider
+ when(kmsProvider.getProviderName()).thenReturn(testProviderName);
+ }
+
+ /**
+ * Test: createUserKMSKey uses explicit HSM profile when provided
+ */
+ @Test
+ public void testCreateUserKMSKey_WithExplicitProfile() throws Exception {
+ // Setup: Explicit profile name provided
+ String hsmProfileName = "user-hsm-profile";
+ Long hsmProfileId = 10L;
+
+ HSMProfileVO profile = mock(HSMProfileVO.class);
+ when(profile.getId()).thenReturn(hsmProfileId);
+ when(profile.getAccountId()).thenReturn(testAccountId);
+ when(hsmProfileDao.findByName(hsmProfileName)).thenReturn(profile);
+
+ // Mock provider KEK creation
+ when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(hsmProfileId)))
+ .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 to return our mock provider
+ 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, hsmProfileName);
+
+ // Verify explicit profile was used
+ assertNotNull(result);
+ verify(hsmProfileDao).findByName(hsmProfileName);
+ verify(kmsProvider).createKek(any(KeyPurpose.class), anyString(), eq(256), eq(hsmProfileId));
+
+ // Verify KMSKeyVO was created with correct profile ID
+ ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(KMSKeyVO.class);
+ verify(kmsKeyDao).persist(keyCaptor.capture());
+ KMSKeyVO createdKey = keyCaptor.getValue();
+ 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
+ */
+ @Test(expected = KMSException.class)
+ 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);
+
+ 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: createUserKMSKey creates KEK version with correct profile ID
+ */
+ @Test
+ public void testCreateUserKMSKey_CreatesKekVersionWithProfileId() throws Exception {
+ // Setup
+ Long hsmProfileId = 40L;
+
+ HSMProfileVO profile = mock(HSMProfileVO.class);
+ when(profile.getId()).thenReturn(hsmProfileId);
+ when(profile.isEnabled()).thenReturn(true);
+ when(profile.getProtocol()).thenReturn(testProviderName);
+ when(hsmProfileDao.listByAccountId(testAccountId)).thenReturn(Arrays.asList(profile));
+
+ when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(hsmProfileId)))
+ .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(kmsProvider).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 version was created with correct profile ID
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(KMSKekVersionVO.class);
+ verify(kmsKekVersionDao).persist(versionCaptor.capture());
+ KMSKekVersionVO createdVersion = versionCaptor.getValue();
+ assertEquals(hsmProfileId, createdVersion.getHsmProfileId());
+ 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/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyRotationTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyRotationTest.java
new file mode 100644
index 00000000000..34a76fa6ef3
--- /dev/null
+++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyRotationTest.java
@@ -0,0 +1,334 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.cloudstack.kms;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+
+import org.apache.cloudstack.framework.kms.KMSException;
+import org.apache.cloudstack.framework.kms.KMSProvider;
+import org.apache.cloudstack.framework.kms.KeyPurpose;
+import org.apache.cloudstack.framework.kms.WrappedKey;
+import org.apache.cloudstack.kms.dao.HSMProfileDao;
+import org.apache.cloudstack.kms.dao.KMSKekVersionDao;
+import org.apache.cloudstack.kms.dao.KMSKeyDao;
+import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+
+/**
+ * Unit tests for KMS key rotation logic in KMSManagerImpl
+ * Tests key rotation within same HSM and cross-HSM migration
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class KMSManagerImplKeyRotationTest {
+
+ @Spy
+ @InjectMocks
+ private KMSManagerImpl kmsManager;
+
+ @Mock
+ private KMSKeyDao kmsKeyDao;
+
+ @Mock
+ private KMSKekVersionDao kmsKekVersionDao;
+
+ @Mock
+ private KMSWrappedKeyDao kmsWrappedKeyDao;
+
+ @Mock
+ private HSMProfileDao hsmProfileDao;
+
+ @Mock
+ private KMSProvider kmsProvider;
+
+ private Long testZoneId = 1L;
+ private String testProviderName = "pkcs11";
+
+ @Before
+ public void setUp() {
+ when(kmsProvider.getProviderName()).thenReturn(testProviderName);
+ }
+
+ /**
+ * Test: rotateKek creates new KEK version in same HSM
+ */
+ @Test
+ public void testRotateKek_SameHSM() throws Exception {
+ // Setup: Rotating within same HSM
+ Long oldProfileId = 10L;
+ Long kmsKeyId = 1L;
+ String oldKekLabel = "old-kek-label";
+ String newKekLabel = "new-kek-label";
+
+ KMSKeyVO kmsKey = mock(KMSKeyVO.class);
+ when(kmsKey.getId()).thenReturn(kmsKeyId);
+ when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId);
+ when(kmsKeyDao.findByKekLabel(oldKekLabel, testProviderName)).thenReturn(kmsKey);
+
+ // Old version should be marked as Previous
+ KMSKekVersionVO oldVersion = mock(KMSKekVersionVO.class);
+ when(oldVersion.getVersionNumber()).thenReturn(1);
+ when(oldVersion.getId()).thenReturn(10L);
+ when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion);
+ when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(Arrays.asList(oldVersion));
+
+ // Provider creates new KEK
+ when(kmsProvider.createKek(any(KeyPurpose.class), eq(newKekLabel), anyInt(), eq(oldProfileId)))
+ .thenReturn("new-kek-id");
+
+ KMSKekVersionVO newVersion = mock(KMSKekVersionVO.class);
+ when(newVersion.getVersionNumber()).thenReturn(2);
+ when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion);
+
+ doReturn(kmsProvider).when(kmsManager).getKMSProviderForZone(testZoneId);
+ doReturn(true).when(kmsManager).isKmsEnabled(testZoneId);
+
+ String result = kmsManager.rotateKek(testZoneId, KeyPurpose.VOLUME_ENCRYPTION,
+ oldKekLabel, newKekLabel, 256, null);
+
+ // Verify new KEK was created in same HSM
+ assertNotNull(result);
+ verify(kmsProvider).createKek(any(KeyPurpose.class), eq(newKekLabel), eq(256), eq(oldProfileId));
+
+ // Verify old version marked as Previous
+ verify(oldVersion).setStatus(KMSKekVersionVO.Status.Previous);
+ verify(kmsKekVersionDao).update(eq(10L), eq(oldVersion));
+
+ // Verify new version created
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(KMSKekVersionVO.class);
+ verify(kmsKekVersionDao).persist(versionCaptor.capture());
+ KMSKekVersionVO createdVersion = versionCaptor.getValue();
+ assertEquals(Integer.valueOf(2), Integer.valueOf(createdVersion.getVersionNumber()));
+ assertEquals(oldProfileId, createdVersion.getHsmProfileId());
+ }
+
+ /**
+ * Test: rotateKek migrates key to different HSM
+ */
+ @Test
+ public void testRotateKek_CrossHSMMigration() throws Exception {
+ // Setup: Rotating to different HSM
+ Long oldProfileId = 10L;
+ Long newProfileId = 20L;
+ Long kmsKeyId = 1L;
+ String oldKekLabel = "old-kek-label";
+ String newKekLabel = "new-kek-label";
+
+ KMSKeyVO kmsKey = mock(KMSKeyVO.class);
+ when(kmsKey.getId()).thenReturn(kmsKeyId);
+ when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId);
+ when(kmsKeyDao.findByKekLabel(oldKekLabel, testProviderName)).thenReturn(kmsKey);
+
+ KMSKekVersionVO oldVersion = mock(KMSKekVersionVO.class);
+ when(oldVersion.getVersionNumber()).thenReturn(1);
+ when(oldVersion.getId()).thenReturn(10L);
+ when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion);
+ when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(Arrays.asList(oldVersion));
+
+ // Provider creates new KEK in different HSM
+ when(kmsProvider.createKek(any(KeyPurpose.class), eq(newKekLabel), anyInt(), eq(newProfileId)))
+ .thenReturn("new-kek-id");
+
+ KMSKekVersionVO newVersion = mock(KMSKekVersionVO.class);
+ when(newVersion.getVersionNumber()).thenReturn(2);
+ when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion);
+
+ doReturn(kmsProvider).when(kmsManager).getKMSProviderForZone(testZoneId);
+ doReturn(true).when(kmsManager).isKmsEnabled(testZoneId);
+
+ String result = kmsManager.rotateKek(testZoneId, KeyPurpose.VOLUME_ENCRYPTION,
+ oldKekLabel, newKekLabel, 256, newProfileId);
+
+ // Verify new KEK was created in new HSM
+ assertNotNull(result);
+ verify(kmsProvider).createKek(any(KeyPurpose.class), eq(newKekLabel), eq(256), eq(newProfileId));
+
+ // Verify new version created with new profile ID
+ ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(KMSKekVersionVO.class);
+ verify(kmsKekVersionDao).persist(versionCaptor.capture());
+ KMSKekVersionVO createdVersion = versionCaptor.getValue();
+ assertEquals(newProfileId, createdVersion.getHsmProfileId());
+
+ // Verify KMS key updated with new profile ID
+ verify(kmsKey).setHsmProfileId(newProfileId);
+ verify(kmsKeyDao).update(kmsKeyId, kmsKey);
+ }
+
+ /**
+ * Test: rewrapSingleKey unwraps with old KEK and wraps with new KEK
+ */
+ @Test
+ public void testRewrapSingleKey_UnwrapAndRewrap() throws Exception {
+ // Setup
+ Long wrappedKeyId = 100L;
+ Long oldVersionId = 1L;
+ Long newVersionId = 2L;
+ Long oldProfileId = 10L;
+ Long newProfileId = 20L;
+
+ KMSWrappedKeyVO wrappedKeyVO = mock(KMSWrappedKeyVO.class);
+ when(wrappedKeyVO.getId()).thenReturn(wrappedKeyId);
+
+ KMSKeyVO kmsKey = mock(KMSKeyVO.class);
+ when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION);
+
+ KMSKekVersionVO oldVersion = mock(KMSKekVersionVO.class);
+
+ KMSKekVersionVO newVersion = mock(KMSKekVersionVO.class);
+ when(newVersion.getId()).thenReturn(newVersionId);
+ when(newVersion.getKekLabel()).thenReturn("new-kek-label");
+ when(newVersion.getHsmProfileId()).thenReturn(newProfileId);
+
+ // Mock unwrap and wrap operations
+ byte[] plainDek = "plain-dek-bytes".getBytes();
+ doReturn(plainDek).when(kmsManager).unwrapKey(wrappedKeyId);
+
+ WrappedKey newWrappedKey = mock(WrappedKey.class);
+ when(newWrappedKey.getWrappedKeyMaterial()).thenReturn("new-wrapped-blob".getBytes());
+ when(kmsProvider.wrapKey(plainDek, KeyPurpose.VOLUME_ENCRYPTION, "new-kek-label", newProfileId))
+ .thenReturn(newWrappedKey);
+
+ kmsManager.rewrapSingleKey(wrappedKeyVO, kmsKey, newVersion, kmsProvider);
+
+ // Verify unwrap was called
+ verify(kmsManager).unwrapKey(wrappedKeyId);
+
+ // Verify wrap was called with new profile
+ verify(kmsProvider).wrapKey(plainDek, KeyPurpose.VOLUME_ENCRYPTION, "new-kek-label", newProfileId);
+
+ // Verify wrapped key was updated
+ verify(wrappedKeyVO).setKekVersionId(newVersionId);
+ verify(wrappedKeyVO).setWrappedBlob("new-wrapped-blob".getBytes());
+ verify(kmsWrappedKeyDao).update(wrappedKeyId, wrappedKeyVO);
+ }
+
+ /**
+ * Test: rotateKek generates new label when not provided
+ */
+ @Test
+ public void testRotateKek_GeneratesLabel() throws Exception {
+ // Setup
+ Long oldProfileId = 10L;
+ Long kmsKeyId = 1L;
+ String oldKekLabel = "old-kek-label";
+
+ KMSKeyVO kmsKey = mock(KMSKeyVO.class);
+ when(kmsKey.getId()).thenReturn(kmsKeyId);
+ when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId);
+ when(kmsKeyDao.findByKekLabel(oldKekLabel, testProviderName)).thenReturn(kmsKey);
+
+ KMSKekVersionVO oldVersion = mock(KMSKekVersionVO.class);
+ when(oldVersion.getVersionNumber()).thenReturn(1);
+ when(oldVersion.getId()).thenReturn(10L);
+ when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion);
+ when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(Arrays.asList(oldVersion));
+
+ // Provider creates new KEK - capture the generated label
+ ArgumentCaptor labelCaptor = ArgumentCaptor.forClass(String.class);
+ when(kmsProvider.createKek(any(KeyPurpose.class), labelCaptor.capture(), anyInt(), eq(oldProfileId)))
+ .thenReturn("new-kek-id");
+
+ KMSKekVersionVO newVersion = mock(KMSKekVersionVO.class);
+ when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion);
+
+ doReturn(kmsProvider).when(kmsManager).getKMSProviderForZone(testZoneId);
+ doReturn(true).when(kmsManager).isKmsEnabled(testZoneId);
+
+ kmsManager.rotateKek(testZoneId, KeyPurpose.VOLUME_ENCRYPTION,
+ oldKekLabel, null, 256, null);
+
+ // Verify a label was generated
+ String generatedLabel = labelCaptor.getValue();
+ assertNotNull("Label should be generated", generatedLabel);
+ verify(kmsProvider).createKek(any(KeyPurpose.class), eq(generatedLabel), eq(256), eq(oldProfileId));
+ }
+
+ /**
+ * Test: rotateKek throws exception when old KEK not found
+ */
+ @Test(expected = KMSException.class)
+ public void testRotateKek_ThrowsExceptionWhenOldKekNotFound() throws KMSException {
+ // Setup: Old KEK doesn't exist
+ when(kmsKeyDao.findByKekLabel("non-existent-label", testProviderName)).thenReturn(null);
+
+ doReturn(kmsProvider).when(kmsManager).getKMSProviderForZone(testZoneId);
+ doReturn(true).when(kmsManager).isKmsEnabled(testZoneId);
+
+ kmsManager.rotateKek(testZoneId, KeyPurpose.VOLUME_ENCRYPTION,
+ "non-existent-label", "new-label", 256, null);
+ }
+
+ /**
+ * Test: rotateKek uses current profile when target profile is null
+ */
+ @Test
+ public void testRotateKek_UsesCurrentProfileWhenTargetNull() throws Exception {
+ // Setup
+ Long currentProfileId = 10L;
+ Long kmsKeyId = 1L;
+ String oldKekLabel = "old-kek-label";
+
+ KMSKeyVO kmsKey = mock(KMSKeyVO.class);
+ when(kmsKey.getId()).thenReturn(kmsKeyId);
+ when(kmsKey.getHsmProfileId()).thenReturn(currentProfileId);
+ when(kmsKeyDao.findByKekLabel(oldKekLabel, testProviderName)).thenReturn(kmsKey);
+
+ KMSKekVersionVO oldVersion = mock(KMSKekVersionVO.class);
+ when(oldVersion.getVersionNumber()).thenReturn(1);
+ when(oldVersion.getId()).thenReturn(10L);
+ when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion);
+ when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(Arrays.asList(oldVersion));
+
+ when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(currentProfileId)))
+ .thenReturn("new-kek-id");
+
+ KMSKekVersionVO newVersion = mock(KMSKekVersionVO.class);
+ when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion);
+
+ doReturn(kmsProvider).when(kmsManager).getKMSProviderForZone(testZoneId);
+ doReturn(true).when(kmsManager).isKmsEnabled(testZoneId);
+
+ kmsManager.rotateKek(testZoneId, KeyPurpose.VOLUME_ENCRYPTION,
+ oldKekLabel, "new-label", 256, null);
+
+ // Verify current profile was used (not a different one)
+ verify(kmsProvider).createKek(any(KeyPurpose.class), anyString(), eq(256), eq(currentProfileId));
+
+ // Verify KMS key was not updated (same profile)
+ verify(kmsKey, never()).setHsmProfileId(currentProfileId);
+ verify(kmsKeyDao, never()).update(kmsKeyId, kmsKey);
+ }
+}
diff --git a/tools/apidoc/gen_toc.py b/tools/apidoc/gen_toc.py
index 243fd9eeb57..40164e25ce8 100644
--- a/tools/apidoc/gen_toc.py
+++ b/tools/apidoc/gen_toc.py
@@ -57,6 +57,7 @@ known_categories = {
'Domain': 'Domain',
'Template': 'Template',
'KMS': 'KMS',
+ 'HSM': 'KMS',
'Iso': 'ISO',
'Volume': 'Volume',
'Vlan': 'VLAN',