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',