Add some tests

This commit is contained in:
vishesh92 2026-01-19 18:03:20 +05:30
parent 6abcffa9d7
commit 0d39a7b0be
No known key found for this signature in database
GPG Key ID: 4E395186CBFA790B
11 changed files with 1324 additions and 117 deletions

View File

@ -315,4 +315,6 @@
<bean id="kmsKeyDaoImpl" class="org.apache.cloudstack.kms.dao.KMSKeyDaoImpl" />
<bean id="kmsKekVersionDaoImpl" class="org.apache.cloudstack.kms.dao.KMSKekVersionDaoImpl" />
<bean id="kmsWrappedKeyDaoImpl" class="org.apache.cloudstack.kms.dao.KMSWrappedKeyDaoImpl" />
<bean id="hsmProfileDaoImpl" class="org.apache.cloudstack.kms.dao.HSMProfileDaoImpl" />
<bean id="hsmProfileDetailsDaoImpl" class="org.apache.cloudstack.kms.dao.HSMProfileDetailsDaoImpl" />
</beans>

View File

@ -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
*/

View File

@ -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.
* <p>
@ -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<String> listKeks(KeyPurpose purpose) throws KMSException;
/**
* Check if a KEK exists and is accessible

View File

@ -183,31 +183,6 @@ public class DatabaseKMSProvider extends AdapterBase implements KMSProvider {
}
}
@Override
public List<String> listKeks(KeyPurpose purpose) throws KMSException {
try {
List<String> keks = new ArrayList<>();
List<KMSDatabaseKekObjectVO> 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 {

View File

@ -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<Long, HSMSessionPool> sessionPools = new ConcurrentHashMap<>();
// Profile configuration caching
private final Map<Long, Map<String, String>> 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<String> 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<String, String> loadProfileConfig(Long profileId) {
Map<String, String> loadProfileConfig(Long profileId) {
return profileConfigCache.computeIfAbsent(profileId, id -> {
List<HSMProfileDetailsVO> details = hsmProfileDetailsDao.listByProfileId(id);
Map<String, String> 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<String, String> config;
private final int maxSessions;
private final int minIdleSessions;
HSMSessionPool(Long profileId, Map<String, String> 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<String, String> config;
private KeyStore keyStore;
private Provider provider;
PKCS11Session(Map<String, String> 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;
}

View File

@ -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<String, String> 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<String, String> 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);
}
}

View File

@ -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<String, KMSProvider> 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<String, String> 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<HSMProfile> listHSMProfiles(ListHSMProfilesCmd cmd) {
Long accountId = CallContext.current().getCallingAccount().getId();
boolean isAdmin = accountManager.isAdmin(accountId);
List<HSMProfile> 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<String, String> 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<HSMProfileDetailsVO> details = hsmProfileDetailsDao.listByProfileId(profile.getId());
Map<String, String> 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");
}

View File

@ -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);
}
}

View File

@ -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<KMSKeyVO> 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<KMSKeyVO> 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<KMSKeyVO> 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<KMSKekVersionVO> 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<KMSKeyVO> keyCaptor = ArgumentCaptor.forClass(KMSKeyVO.class);
verify(kmsKeyDao).persist(keyCaptor.capture());
assertEquals(null, keyCaptor.getValue().getHsmProfileId());
}
}

View File

@ -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<KMSKekVersionVO> 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<KMSKekVersionVO> 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<String> 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);
}
}

View File

@ -57,6 +57,7 @@ known_categories = {
'Domain': 'Domain',
'Template': 'Template',
'KMS': 'KMS',
'HSM': 'KMS',
'Iso': 'ISO',
'Volume': 'Volume',
'Vlan': 'VLAN',