diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index 72301d4bda0..28324fc147e 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -278,6 +278,7 @@ public class EventTypes { public static final String EVENT_KMS_KEK_ROTATE = "KMS.KEK.ROTATE"; public static final String EVENT_KMS_KEK_DELETE = "KMS.KEK.DELETE"; public static final String EVENT_KMS_HEALTH_CHECK = "KMS.HEALTH.CHECK"; + public static final String EVENT_VOLUME_MIGRATE_TO_KMS = "VOLUME.MIGRATE.TO.KMS"; // Account events public static final String EVENT_ACCOUNT_ENABLE = "ACCOUNT.ENABLE"; diff --git a/api/src/main/java/com/cloud/storage/Volume.java b/api/src/main/java/com/cloud/storage/Volume.java index c7fbdb0a544..c7a13d5780d 100644 --- a/api/src/main/java/com/cloud/storage/Volume.java +++ b/api/src/main/java/com/cloud/storage/Volume.java @@ -275,6 +275,14 @@ public interface Volume extends ControlledEntity, Identity, InternalIdentity, Ba void setPassphraseId(Long id); + Long getKmsKeyId(); + + void setKmsKeyId(Long id); + + Long getKmsWrappedKeyId(); + + void setKmsWrappedKeyId(Long id); + String getEncryptFormat(); void setEncryptFormat(String encryptFormat); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java new file mode 100644 index 00000000000..43e77d28f44 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java @@ -0,0 +1,130 @@ +// 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.api.command.admin.kms; + +import com.cloud.user.Account; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AsyncJobResponse; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "migrateVolumesToKMS", + description = "Migrates passphrase-based volumes to KMS (admin only)", + responseObject = AsyncJobResponse.class, + since = "4.23.0", + authorized = {RoleType.Admin}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class MigrateVolumesToKMSCmd extends BaseAsyncCmd { + private static final String s_name = "migratevolumestokmsresponse"; + + @Inject + private KMSManager kmsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ZONE_ID, + required = true, + type = CommandType.UUID, + entityType = ZoneResponse.class, + description = "Zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.ACCOUNT, + type = CommandType.STRING, + description = "Migrate volumes for specific account") + private String accountName; + + @Parameter(name = ApiConstants.DOMAIN_ID, + type = CommandType.UUID, + entityType = DomainResponse.class, + description = "Domain ID") + private Long domainId; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getZoneId() { + return zoneId; + } + + public String getAccountName() { + return accountName; + } + + public Long getDomainId() { + return domainId; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() { + try { + kmsManager.migrateVolumesToKMS(this); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + "Failed to migrate volumes to KMS: " + e.getMessage()); + } + } + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public String getEventType() { + return com.cloud.event.EventTypes.EVENT_VOLUME_MIGRATE_TO_KMS; + } + + @Override + public String getEventDescription() { + return "Migrating volumes to KMS for zone: " + _uuidMgr.getUuid(ZoneResponse.class, zoneId); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Zone; + } + + @Override + public Long getApiResourceId() { + return zoneId; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/RotateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/RotateKMSKeyCmd.java new file mode 100644 index 00000000000..8cea1b2cc82 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/RotateKMSKeyCmd.java @@ -0,0 +1,119 @@ +// 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.api.command.admin.kms; + +import com.cloud.user.Account; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AsyncJobResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "rotateKMSKey", + description = "Rotates KEK by creating new version and scheduling gradual re-encryption (admin only)", + responseObject = AsyncJobResponse.class, + since = "4.23.0", + authorized = {RoleType.Admin}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class RotateKMSKeyCmd extends BaseAsyncCmd { + private static final String s_name = "rotatekmskeyresponse"; + + @Inject + private KMSManager kmsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ID, + required = true, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "KMS Key UUID to rotate") + private Long id; + + @Parameter(name = ApiConstants.KEY_BITS, + type = CommandType.INTEGER, + description = "Key size for new KEK (default: same as current)") + private Integer keyBits; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public Integer getKeyBits() { + return keyBits; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() { + try { + kmsManager.rotateKMSKey(this); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + "Failed to rotate KMS key: " + e.getMessage()); + } + } + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public String getEventType() { + return com.cloud.event.EventTypes.EVENT_KMS_KEK_ROTATE; + } + + @Override + public String getEventDescription() { + return "Rotating KMS key: " + _uuidMgr.getUuid(KMSKeyResponse.class, id); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.KmsKey; + } + + @Override + public Long getApiResourceId() { + return id; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java index 08964a88373..fca01702ed7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java @@ -47,7 +47,6 @@ import javax.inject.Inject; requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { - private static final String s_name = "createkmskeyresponse"; @Inject private KMSManager kmsManager; @@ -70,7 +69,7 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { @Parameter(name = ApiConstants.PURPOSE, required = true, type = CommandType.STRING, - description = "Purpose of the key: VOLUME_ENCRYPTION, TLS_CERT, CONFIG_SECRET") + description = "Purpose of the key: volume, tls") private String purpose; @Parameter(name = ApiConstants.ZONE_ID, @@ -144,11 +143,6 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { } } - @Override - public String getCommandName() { - return s_name; - } - @Override public long getEntityOwnerId() { Account caller = CallContext.current().getCallingAccount(); @@ -163,4 +157,3 @@ public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { return ApiCommandResourceType.KmsKey; } } - diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java index ab0d8c321b1..bd6a4bd1fd6 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java @@ -45,7 +45,6 @@ import javax.inject.Inject; requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) public class DeleteKMSKeyCmd extends BaseAsyncCmd implements UserCmd { - private static final String s_name = "deletekmskeyresponse"; @Inject private KMSManager kmsManager; @@ -85,11 +84,6 @@ public class DeleteKMSKeyCmd extends BaseAsyncCmd implements UserCmd { } } - @Override - public String getCommandName() { - return s_name; - } - @Override public long getEntityOwnerId() { return CallContext.current().getCallingAccount().getId(); @@ -110,4 +104,3 @@ public class DeleteKMSKeyCmd extends BaseAsyncCmd implements UserCmd { return ApiCommandResourceType.KmsKey; } } - diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java index e15560f9599..a428854e6a3 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java @@ -59,7 +59,7 @@ public class ListKMSKeysCmd extends BaseListAccountResourcesCmd implements UserC @Parameter(name = ApiConstants.PURPOSE, type = CommandType.STRING, - description = "Filter by purpose: VOLUME_ENCRYPTION, TLS_CERT, CONFIG_SECRET") + description = "Filter by purpose: volume, tls") private String purpose; @Parameter(name = ApiConstants.ZONE_ID, @@ -109,4 +109,3 @@ public class ListKMSKeysCmd extends BaseListAccountResourcesCmd implements UserC return s_name; } } - diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java index 62146a30ae7..673fb0e719b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java @@ -44,7 +44,6 @@ import javax.inject.Inject; requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) public class UpdateKMSKeyCmd extends BaseAsyncCmd implements UserCmd { - private static final String s_name = "updatekmskeyresponse"; @Inject private KMSManager kmsManager; @@ -111,11 +110,6 @@ public class UpdateKMSKeyCmd extends BaseAsyncCmd implements UserCmd { } } - @Override - public String getCommandName() { - return s_name; - } - @Override public long getEntityOwnerId() { return CallContext.current().getCallingAccount().getId(); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java index 5bcf3a14117..cb9253f11db 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java @@ -30,6 +30,7 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.DiskOfferingResponse; import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.ProjectResponse; import org.apache.cloudstack.api.response.SnapshotResponse; import org.apache.cloudstack.api.response.UserVmResponse; @@ -109,6 +110,13 @@ public class CreateVolumeCmd extends BaseAsyncCreateCustomIdCmd implements UserC description = "The ID of the Instance; to be used with snapshot Id, Instance to which the volume gets attached after creation") private Long virtualMachineId; + @Parameter(name = ApiConstants.KMS_KEY_ID, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "ID of the KMS Key for volume encryption (required if encryption enabled for zone)", + since = "4.23.0") + private Long kmsKeyId; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -169,6 +177,10 @@ public class CreateVolumeCmd extends BaseAsyncCreateCustomIdCmd implements UserC return virtualMachineId; } + public Long getKmsKeyId() { + return kmsKeyId; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java index df9967a19c0..e2ab7c48851 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java @@ -45,7 +45,7 @@ public class KMSKeyResponse extends BaseResponse implements ControlledEntityResp private String description; @SerializedName(ApiConstants.PURPOSE) - @Param(description = "the purpose of the key (VOLUME_ENCRYPTION, TLS_CERT, CONFIG_SECRET)") + @Param(description = "the purpose of the key (VOLUME_ENCRYPTION, TLS_CERT)") private String purpose; @SerializedName(ApiConstants.ACCOUNT) @@ -253,4 +253,3 @@ public class KMSKeyResponse extends BaseResponse implements ControlledEntityResp this.kekLabel = kekLabel; } } - diff --git a/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java b/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java index 507b5a5058b..d0397df8180 100644 --- a/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java +++ b/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java @@ -101,4 +101,3 @@ public interface KMSKey extends Identity, InternalIdentity, ControlledEntity { Deleted } } - diff --git a/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java b/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java index 0f9d6ef54dd..569d76ae336 100644 --- a/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java +++ b/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java @@ -18,6 +18,8 @@ package org.apache.cloudstack.kms; import com.cloud.utils.component.Manager; +import org.apache.cloudstack.api.command.admin.kms.MigrateVolumesToKMSCmd; +import org.apache.cloudstack.api.command.admin.kms.RotateKMSKeyCmd; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; import org.apache.cloudstack.api.command.user.kms.CreateKMSKeyCmd; @@ -288,30 +290,8 @@ public interface KMSManager extends Manager, Configurable { * @param keyUuid the key UUID * @return true if caller has permission */ - boolean hasPermission(Long callerAccountId, String keyUuid); + boolean hasPermission(Long callerAccountId, KMSKey key); - /** - * Delete a KMS key (only if not in use) - * - * @param uuid the key UUID - * @param callerAccountId the caller's account ID - * @throws KMSException if deletion fails (e.g., key in use) - */ - void deleteUserKMSKey(String uuid, Long callerAccountId) throws KMSException; - - /** - * Update a KMS key's metadata (name, description, state) - * - * @param uuid the key UUID - * @param callerAccountId the caller's account ID - * @param name optional new name - * @param description optional new description - * @param state optional new state - * @return the updated KMS key - * @throws KMSException if update fails - */ - KMSKey updateUserKMSKey(String uuid, Long callerAccountId, - String name, String description, KMSKey.State state) throws KMSException; /** * Unwrap a DEK by wrapped key ID, trying multiple KEK versions if needed @@ -330,7 +310,7 @@ public interface KMSManager extends Manager, Configurable { * @return wrapped key ready for database storage * @throws KMSException if operation fails */ - WrappedKey generateVolumeKeyWithKek(String kekUuid, Long callerAccountId) throws KMSException; + WrappedKey generateVolumeKeyWithKek(KMSKey kmsKey, Long callerAccountId) throws KMSException; // ==================== API Response Methods ==================== @@ -372,4 +352,35 @@ public interface KMSManager extends Manager, Configurable { * @throws KMSException if deletion fails */ SuccessResponse deleteKMSKey(DeleteKMSKeyCmd cmd) throws KMSException; + + // ==================== Admin Operations ==================== + + /** + * Rotate KEK by creating new version and scheduling gradual re-encryption + * + * @param cmd the rotate command with all parameters + * @return New KEK version UUID + * @throws KMSException if rotation fails + */ + String rotateKMSKey(RotateKMSKeyCmd cmd) throws KMSException; + + /** + * Gradually rewrap all wrapped keys for a KMS key to use new KEK version + * + * @param kmsKeyId KMS key ID + * @param newKekVersionId New active KEK version ID + * @param batchSize Number of keys to process per batch + * @return Number of keys successfully rewrapped + * @throws KMSException if rewrap fails + */ + int rewrapWrappedKeysForKMSKey(Long kmsKeyId, Long newKekVersionId, int batchSize) throws KMSException; + + /** + * Migrate passphrase-based volumes to KMS encryption + * + * @param cmd the migrate command with all parameters + * @return Number of volumes successfully migrated + * @throws KMSException if migration fails + */ + int migrateVolumesToKMS(MigrateVolumesToKMSCmd cmd) throws KMSException; } diff --git a/client/pom.xml b/client/pom.xml index b8dffe65d4f..30ae1612301 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -251,6 +251,11 @@ cloud-plugin-metrics ${project.version} + + org.apache.cloudstack + cloud-plugin-kms-database + ${project.version} + org.apache.cloudstack cloud-plugin-network-nvp diff --git a/core/src/main/resources/META-INF/cloudstack/kms/module.properties b/core/src/main/resources/META-INF/cloudstack/kms/module.properties new file mode 100644 index 00000000000..98e38d7cd8f --- /dev/null +++ b/core/src/main/resources/META-INF/cloudstack/kms/module.properties @@ -0,0 +1,21 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +name=kms +parent=core diff --git a/core/src/main/resources/META-INF/cloudstack/kms/spring-core-lifecycle-kms-context-inheritable.xml b/core/src/main/resources/META-INF/cloudstack/kms/spring-core-lifecycle-kms-context-inheritable.xml new file mode 100644 index 00000000000..9226eef8fc1 --- /dev/null +++ b/core/src/main/resources/META-INF/cloudstack/kms/spring-core-lifecycle-kms-context-inheritable.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java index e8c75afa81c..dfffca1c4bc 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java @@ -85,6 +85,13 @@ import org.apache.cloudstack.resourcedetail.dao.DiskOfferingDetailsDao; import org.apache.cloudstack.secret.PassphraseVO; import org.apache.cloudstack.secret.dao.PassphraseDao; import org.apache.cloudstack.snapshot.SnapshotHelper; +import org.apache.cloudstack.kms.KMSManager; +import org.apache.cloudstack.kms.KMSKeyVO; +import org.apache.cloudstack.kms.KMSWrappedKeyVO; +import org.apache.cloudstack.kms.dao.KMSKeyDao; +import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.WrappedKey; import org.apache.cloudstack.storage.command.CommandResult; import org.apache.cloudstack.storage.datastore.db.ImageStoreDao; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; @@ -279,6 +286,12 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati @Inject private DataStoreProviderManager dataStoreProviderMgr; + @Inject + private KMSManager kmsManager; + @Inject + private KMSKeyDao kmsKeyDao; + @Inject + private KMSWrappedKeyDao kmsWrappedKeyDao; private final StateMachine2 _volStateMachine; protected List _storagePoolAllocators; @@ -507,7 +520,9 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati DiskOffering diskOffering = _entityMgr.findById(DiskOffering.class, volume.getDiskOfferingId()); if (diskOffering.getEncrypt()) { VolumeVO vol = (VolumeVO) volume; - volume = setPassphraseForVolumeEncryption(vol); + // Retrieve KMS key from volume's kmsKeyId if provided + KMSKeyVO kmsKey = getKmsKeyFromVolume(vol); + volume = setPassphraseForVolumeEncryption(vol, kmsKey, volume.getAccountId()); } DataCenter dc = _entityMgr.findById(DataCenter.class, volume.getDataCenterId()); DiskProfile dskCh = new DiskProfile(volume, diskOffering, snapshot.getHypervisorType()); @@ -724,7 +739,9 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati if (diskOffering.getEncrypt()) { VolumeVO vol = _volsDao.findById(volumeInfo.getId()); - setPassphraseForVolumeEncryption(vol); + // Retrieve KMS key from volume's kmsKeyId if provided + KMSKeyVO kmsKey = getKmsKeyFromVolume(vol); + setPassphraseForVolumeEncryption(vol, kmsKey, vol.getAccountId()); volumeInfo = volFactory.getVolume(volumeInfo.getId()); } } @@ -1775,7 +1792,9 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati if (vol.getState() == Volume.State.Allocated || vol.getState() == Volume.State.Creating) { DiskOffering diskOffering = _entityMgr.findById(DiskOffering.class, vol.getDiskOfferingId()); if (diskOffering.getEncrypt()) { - vol = setPassphraseForVolumeEncryption(vol); + // Retrieve KMS key from volume's kmsKeyId if provided + KMSKeyVO kmsKey = getKmsKeyFromVolume(vol); + vol = setPassphraseForVolumeEncryption(vol, kmsKey, vol.getAccountId()); } newVol = vol; } else { @@ -1898,10 +1917,68 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati return new Pair<>(newVol, destPool); } + /** + * Helper method to retrieve KMS key from volume's kmsKeyId + */ + private KMSKeyVO getKmsKeyFromVolume(VolumeVO volume) { + if (volume.getKmsKeyId() == null) { + return null; + } + return kmsKeyDao.findById(volume.getKmsKeyId()); + } + private VolumeVO setPassphraseForVolumeEncryption(VolumeVO volume) { - if (volume.getPassphraseId() != null) { + return setPassphraseForVolumeEncryption(volume, null, null); + } + + private VolumeVO setPassphraseForVolumeEncryption(VolumeVO volume, KMSKeyVO kmsKey, Long callerAccountId) { + // If volume already has encryption set up, return it + if (volume.getKmsWrappedKeyId() != null || volume.getPassphraseId() != null) { return volume; } + + Long zoneId = volume.getDataCenterId(); + + // Check if KMS is enabled for zone AND KMS key is provided + if (kmsManager != null && kmsManager.isKmsEnabled(zoneId) && kmsKey != null) { + // Determine caller account ID if not provided + if (callerAccountId == null) { + callerAccountId = volume.getAccountId(); + } + + // Validate permission + if (!kmsManager.hasPermission(callerAccountId, kmsKey)) { + throw new CloudRuntimeException("No permission to use KMS key: " + kmsKey); + } + + try { + logger.debug("Generating and wrapping DEK for volume {} using KMS key {}", volume.getName(), kmsKey.getUuid()); + long startTime = System.currentTimeMillis(); + + // Generate and wrap DEK using active KEK version + WrappedKey wrappedKey = kmsManager.generateVolumeKeyWithKek(kmsKey, callerAccountId); + + // The wrapped key is already persisted by generateVolumeKeyWithKek, get its ID + KMSWrappedKeyVO wrappedKeyVO = kmsWrappedKeyDao.findByUuid(wrappedKey.getUuid()); + if (wrappedKeyVO == null) { + throw new CloudRuntimeException("Failed to find persisted wrapped key: " + wrappedKey.getUuid()); + } + + // Set the wrapped key ID on the volume + volume.setKmsWrappedKeyId(wrappedKeyVO.getId()); + + long finishTime = System.currentTimeMillis(); + logger.debug("Generating and persisting wrapped key took {} ms for volume: {}", + (finishTime - startTime), volume.getName()); + + return _volsDao.persist(volume); + + } catch (KMSException e) { + throw new CloudRuntimeException("KMS failure while setting up volume encryption: " + e.getMessage(), e); + } + } + + // Legacy: passphrase-based encryption (fallback when KMS not enabled or KMS key not specified) logger.debug("Creating passphrase for the volume: " + volume.getName()); long startTime = System.currentTimeMillis(); PassphraseVO passphrase = passphraseDao.persist(new PassphraseVO(true)); diff --git a/engine/schema/src/main/java/com/cloud/storage/VolumeVO.java b/engine/schema/src/main/java/com/cloud/storage/VolumeVO.java index 126c8144d35..3815125b348 100644 --- a/engine/schema/src/main/java/com/cloud/storage/VolumeVO.java +++ b/engine/schema/src/main/java/com/cloud/storage/VolumeVO.java @@ -182,6 +182,9 @@ public class VolumeVO implements Volume { @Column(name = "passphrase_id") private Long passphraseId; + @Column(name = "kms_key_id") + private Long kmsKeyId; + @Column(name = "kms_wrapped_key_id") private Long kmsWrappedKeyId; @@ -686,6 +689,10 @@ public class VolumeVO implements Volume { public void setPassphraseId(Long id) { this.passphraseId = id; } + public Long getKmsKeyId() { return kmsKeyId; } + + public void setKmsKeyId(Long id) { this.kmsKeyId = id; } + public Long getKmsWrappedKeyId() { return kmsWrappedKeyId; } public void setKmsWrappedKeyId(Long id) { this.kmsWrappedKeyId = id; } diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java index a03b94faa79..a4b109aba31 100644 --- a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java +++ b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java @@ -109,6 +109,17 @@ public interface VolumeDao extends GenericDao, StateDao listVolumesByPassphraseId(long passphraseId); + /** + * List volumes with passphrase_id for migration to KMS + * + * @param zoneId Zone ID (required) + * @param accountId Account ID filter (optional, null for all accounts) + * @param domainId Domain ID filter (optional, null for all domains) + * @param limit Maximum number of volumes to return + * @return list of volumes that need migration + */ + Pair, Integer> listVolumesForKMSMigration(Long zoneId, Long accountId, Long domainId, Integer limit); + /** * Gets the Total Primary Storage space allocated for an account * diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java index 727c4fe8ef2..36b7801ccff 100644 --- a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java @@ -79,6 +79,7 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol protected GenericSearchBuilder secondaryStorageSearch; private final SearchBuilder poolAndPathSearch; final GenericSearchBuilder CountByOfferingId; + private final SearchBuilder kmsMigrationSearch; @Inject ReservationDao reservationDao; @@ -512,6 +513,13 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol CountByOfferingId.select(null, Func.COUNT, CountByOfferingId.entity().getId()); CountByOfferingId.and("diskOfferingId", CountByOfferingId.entity().getDiskOfferingId(), Op.EQ); CountByOfferingId.done(); + + kmsMigrationSearch = createSearchBuilder(); + kmsMigrationSearch.and("passphraseId", kmsMigrationSearch.entity().getPassphraseId(), Op.NNULL); + kmsMigrationSearch.and("zoneId", kmsMigrationSearch.entity().getDataCenterId(), Op.EQ); + kmsMigrationSearch.and("accountId", kmsMigrationSearch.entity().getAccountId(), Op.EQ); + kmsMigrationSearch.and("domainId", kmsMigrationSearch.entity().getDomainId(), Op.EQ); + kmsMigrationSearch.done(); } @Override @@ -732,6 +740,23 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol return listBy(sc); } + @Override + public Pair, Integer> listVolumesForKMSMigration(Long zoneId, Long accountId, Long domainId, Integer limit) { + SearchCriteria sc = kmsMigrationSearch.create(); + + Filter filter = new Filter(limit); + sc.setParameters("zoneId", zoneId); + if (accountId != null) { + sc.setParameters("accountId", accountId); + } + if (domainId != null) { + sc.setParameters("domainId", domainId); + } + Integer count = getCount(sc); + List volumes = listBy(sc, filter); + return new Pair<>(volumes, count); + } + @Override @DB public boolean remove(Long id) { diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java index 36f9661b0fc..8d007732d7d 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java @@ -18,6 +18,7 @@ package org.apache.cloudstack.kms; import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import javax.persistence.Column; import javax.persistence.Entity; @@ -89,9 +90,6 @@ public class KMSKekVersionVO { Archived } - /** - * Default constructor (required by JPA) - */ public KMSKekVersionVO() { this.uuid = UUID.randomUUID().toString(); this.created = new Date(); @@ -114,8 +112,6 @@ public class KMSKekVersionVO { this.status = status; } - // Getters and Setters - public Long getId() { return id; } @@ -182,8 +178,8 @@ public class KMSKekVersionVO { @Override public String toString() { - return String.format("KMSKekVersion[id=%d, uuid=%s, kmsKeyId=%d, version=%d, status=%s, kekLabel=%s]", - id, uuid, kmsKeyId, versionNumber, status, kekLabel); + return String.format("KMSKekVersion %s", + ReflectionToStringBuilderUtils.reflectOnlySelectedFields( + this, "id", "uuid", "kmsKeyId", "versionNumber", "status", "kekLabel")); } } - diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java index 16aa6f9ebb5..af03b10950e 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java @@ -19,6 +19,7 @@ package org.apache.cloudstack.kms; import com.cloud.utils.db.GenericDao; import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import javax.persistence.Column; import javax.persistence.Entity; @@ -92,18 +93,12 @@ public class KMSKeyVO implements KMSKey { @Temporal(TemporalType.TIMESTAMP) private Date removed; - /** - * Default constructor (required by JPA) - */ public KMSKeyVO() { this.uuid = UUID.randomUUID().toString(); this.created = new Date(); this.state = State.Enabled; } - /** - * Constructor for creating a new KMS key - */ public KMSKeyVO(String name, String description, String kekLabel, KeyPurpose purpose, Long accountId, Long domainId, Long zoneId, String providerName, String algorithm, Integer keyBits) { @@ -120,8 +115,6 @@ public class KMSKeyVO implements KMSKey { this.keyBits = keyBits; } - // Identity interface methods - @Override public long getId() { return id; @@ -132,8 +125,6 @@ public class KMSKeyVO implements KMSKey { return uuid; } - // KMSKey interface methods - @Override public String getName() { return name; @@ -206,8 +197,6 @@ public class KMSKeyVO implements KMSKey { return KMSKey.class; } - // Setters - public void setId(Long id) { this.id = id; } @@ -270,8 +259,7 @@ public class KMSKeyVO implements KMSKey { @Override public String toString() { - return String.format("KMSKey[id=%d, uuid=%s, name=%s, purpose=%s, account=%d, zone=%d, state=%s]", - id, uuid, name, purpose, accountId, zoneId, state); + return String.format("KMSKey %s", + ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "name", "purpose", "accountId", "zoneId", "state")); } } - diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java index 77f99e88070..827d87612c1 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java @@ -18,6 +18,7 @@ package org.apache.cloudstack.kms; import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import javax.persistence.Column; import javax.persistence.Entity; @@ -68,20 +69,13 @@ public class KMSWrappedKeyVO { @Temporal(TemporalType.TIMESTAMP) private Date removed; - /** - * Constructor for creating a new wrapped key entry - */ public KMSWrappedKeyVO(KMSKeyVO kmsKey, byte[] wrappedBlob) { this(); this.kmsKeyId = kmsKey.getId(); this.zoneId = kmsKey.getZoneId(); - // Defensive copy this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; } - /** - * Constructor for creating a new wrapped key entry with KEK version - */ public KMSWrappedKeyVO(KMSKeyVO kmsKey, Long kekVersionId, byte[] wrappedBlob) { this(); this.kmsKeyId = kmsKey.getId(); @@ -91,39 +85,26 @@ public class KMSWrappedKeyVO { this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; } - /** - * Constructor with explicit parameters - */ public KMSWrappedKeyVO(Long kmsKeyId, Long zoneId, byte[] wrappedBlob) { this(); this.kmsKeyId = kmsKeyId; this.zoneId = zoneId; - // Defensive copy this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; } - /** - * Constructor with explicit parameters including KEK version - */ public KMSWrappedKeyVO(Long kmsKeyId, Long kekVersionId, Long zoneId, byte[] wrappedBlob) { this(); this.kmsKeyId = kmsKeyId; this.kekVersionId = kekVersionId; this.zoneId = zoneId; - // Defensive copy this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; } - /** - * Default constructor (required by JPA) - */ public KMSWrappedKeyVO() { this.uuid = UUID.randomUUID().toString(); this.created = new Date(); } - // Getters and Setters - public Long getId() { return id; } @@ -165,12 +146,10 @@ public class KMSWrappedKeyVO { } public byte[] getWrappedBlob() { - // Return defensive copy return wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; } public void setWrappedBlob(byte[] wrappedBlob) { - // Store defensive copy this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; } @@ -192,16 +171,8 @@ public class KMSWrappedKeyVO { @Override public String toString() { - return "KMSWrappedKeyVO{" + - "id=" + id + - ", uuid='" + uuid + '\'' + - ", kmsKeyId=" + kmsKeyId + - ", kekVersionId=" + kekVersionId + - ", zoneId=" + zoneId + - ", blobSize=" + (wrappedBlob != null ? wrappedBlob.length : 0) + - ", created=" + created + - ", removed=" + removed + - '}'; + return String.format("KMSWrappedKey %s", + ReflectionToStringBuilderUtils.reflectOnlySelectedFields( + this, "id", "uuid", "kmsKeyId", "kekVersionId", "accountId", "zoneId", "state", "created", "removed")); } } - diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDao.java index 75cae5dbbb6..5e61f081b92 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDao.java @@ -22,16 +22,7 @@ import org.apache.cloudstack.kms.KMSKekVersionVO; import java.util.List; -/** - * DAO for KMSKekVersion entities - */ public interface KMSKekVersionDao extends GenericDao { - - /** - * Find a KEK version by UUID - */ - KMSKekVersionVO findByUuid(String uuid); - /** * Get the active version for a KMS key */ @@ -57,4 +48,3 @@ public interface KMSKekVersionDao extends GenericDao { */ KMSKekVersionVO findByKekLabel(String kekLabel); } - diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java index d052d069a39..619400f70b4 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java @@ -25,72 +25,23 @@ import org.springframework.stereotype.Component; import java.util.List; -/** - * Implementation of KMSKekVersionDao - */ @Component public class KMSKekVersionDaoImpl extends GenericDaoBase implements KMSKekVersionDao { - private final SearchBuilder uuidSearch; - private final SearchBuilder kmsKeyIdSearch; - private final SearchBuilder activeVersionSearch; - private final SearchBuilder decryptionVersionsSearch; - private final SearchBuilder versionNumberSearch; - private final SearchBuilder kekLabelSearch; + private final SearchBuilder allFieldSearch; public KMSKekVersionDaoImpl() { - super(); - - // Search by UUID - uuidSearch = createSearchBuilder(); - uuidSearch.and("uuid", uuidSearch.entity().getUuid(), SearchCriteria.Op.EQ); - uuidSearch.and("removed", uuidSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - uuidSearch.done(); - - // Search by KMS key ID - kmsKeyIdSearch = createSearchBuilder(); - kmsKeyIdSearch.and("kmsKeyId", kmsKeyIdSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); - kmsKeyIdSearch.and("removed", kmsKeyIdSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - kmsKeyIdSearch.done(); - - // Search for active version by KMS key ID - activeVersionSearch = createSearchBuilder(); - activeVersionSearch.and("kmsKeyId", activeVersionSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); - activeVersionSearch.and("status", activeVersionSearch.entity().getStatus(), SearchCriteria.Op.EQ); - activeVersionSearch.and("removed", activeVersionSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - activeVersionSearch.done(); - - // Search for versions usable for decryption (Active or Previous) - decryptionVersionsSearch = createSearchBuilder(); - decryptionVersionsSearch.and("kmsKeyId", decryptionVersionsSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); - decryptionVersionsSearch.and("status", decryptionVersionsSearch.entity().getStatus(), SearchCriteria.Op.IN); - decryptionVersionsSearch.and("removed", decryptionVersionsSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - decryptionVersionsSearch.done(); - - // Search by KMS key ID and version number - versionNumberSearch = createSearchBuilder(); - versionNumberSearch.and("kmsKeyId", versionNumberSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); - versionNumberSearch.and("versionNumber", versionNumberSearch.entity().getVersionNumber(), SearchCriteria.Op.EQ); - versionNumberSearch.and("removed", versionNumberSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - versionNumberSearch.done(); - - // Search by KEK label - kekLabelSearch = createSearchBuilder(); - kekLabelSearch.and("kekLabel", kekLabelSearch.entity().getKekLabel(), SearchCriteria.Op.EQ); - kekLabelSearch.and("removed", kekLabelSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - kekLabelSearch.done(); - } - - @Override - public KMSKekVersionVO findByUuid(String uuid) { - SearchCriteria sc = uuidSearch.create(); - sc.setParameters("uuid", uuid); - return findOneBy(sc); + allFieldSearch = createSearchBuilder(); + allFieldSearch.and("kmsKeyId", allFieldSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); + allFieldSearch.and("status", allFieldSearch.entity().getStatus(), SearchCriteria.Op.IN); + allFieldSearch.and("versionNumber", allFieldSearch.entity().getVersionNumber(), SearchCriteria.Op.EQ); + allFieldSearch.and("kekLabel", allFieldSearch.entity().getKekLabel(), SearchCriteria.Op.EQ); + allFieldSearch.done(); } @Override public KMSKekVersionVO getActiveVersion(Long kmsKeyId) { - SearchCriteria sc = activeVersionSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("kmsKeyId", kmsKeyId); sc.setParameters("status", KMSKekVersionVO.Status.Active); return findOneBy(sc); @@ -98,7 +49,7 @@ public class KMSKekVersionDaoImpl extends GenericDaoBase @Override public List getVersionsForDecryption(Long kmsKeyId) { - SearchCriteria sc = decryptionVersionsSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("kmsKeyId", kmsKeyId); sc.setParameters("status", KMSKekVersionVO.Status.Active, KMSKekVersionVO.Status.Previous); return listBy(sc); @@ -106,14 +57,14 @@ public class KMSKekVersionDaoImpl extends GenericDaoBase @Override public List listByKmsKeyId(Long kmsKeyId) { - SearchCriteria sc = kmsKeyIdSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("kmsKeyId", kmsKeyId); return listBy(sc); } @Override public KMSKekVersionVO findByKmsKeyIdAndVersion(Long kmsKeyId, Integer versionNumber) { - SearchCriteria sc = versionNumberSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("kmsKeyId", kmsKeyId); sc.setParameters("versionNumber", versionNumber); return findOneBy(sc); @@ -121,9 +72,8 @@ public class KMSKekVersionDaoImpl extends GenericDaoBase @Override public KMSKekVersionVO findByKekLabel(String kekLabel) { - SearchCriteria sc = kekLabelSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("kekLabel", kekLabel); return findOneBy(sc); } } - diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java index b5f4c619aa3..3105ed236db 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java @@ -24,16 +24,8 @@ import org.apache.cloudstack.kms.KMSKeyVO; import java.util.List; -/** - * DAO for KMSKey entities - */ public interface KMSKeyDao extends GenericDao { - /** - * Find a KMS key by UUID - */ - KMSKeyVO findByUuid(String uuid); - /** * Find a KMS key by KEK label and provider */ @@ -44,11 +36,6 @@ public interface KMSKeyDao extends GenericDao { */ List listByAccount(Long accountId, KeyPurpose purpose, KMSKey.State state); - /** - * List KMS keys in a domain (optionally including subdomains) - */ - List listByDomain(Long domainId, KeyPurpose purpose, KMSKey.State state, boolean includeSubdomains); - /** * List KMS keys in a zone */ @@ -69,4 +56,3 @@ public interface KMSKeyDao extends GenericDao { */ long countByKekLabel(String kekLabel, String providerName); } - diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java index 9e6a58dba55..8a9558306f2 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java @@ -28,83 +28,29 @@ import org.springframework.stereotype.Component; import javax.inject.Inject; import java.util.List; -/** - * Implementation of KMSKeyDao - */ @Component public class KMSKeyDaoImpl extends GenericDaoBase implements KMSKeyDao { - private final SearchBuilder uuidSearch; - private final SearchBuilder kekLabelSearch; - private final SearchBuilder accountSearch; - private final SearchBuilder domainSearch; - private final SearchBuilder zoneSearch; - private final SearchBuilder accessibleSearch; + private final SearchBuilder allFieldSearch; @Inject private KMSWrappedKeyDao kmsWrappedKeyDao; public KMSKeyDaoImpl() { - super(); - - // Search by UUID - uuidSearch = createSearchBuilder(); - uuidSearch.and("uuid", uuidSearch.entity().getUuid(), SearchCriteria.Op.EQ); - uuidSearch.and("removed", uuidSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - uuidSearch.done(); - - // Search by KEK label and provider - kekLabelSearch = createSearchBuilder(); - kekLabelSearch.and("kekLabel", kekLabelSearch.entity().getKekLabel(), SearchCriteria.Op.EQ); - kekLabelSearch.and("providerName", kekLabelSearch.entity().getProviderName(), SearchCriteria.Op.EQ); - kekLabelSearch.and("removed", kekLabelSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - kekLabelSearch.done(); - - // Search by account - accountSearch = createSearchBuilder(); - accountSearch.and("accountId", accountSearch.entity().getAccountId(), SearchCriteria.Op.EQ); - accountSearch.and("purpose", accountSearch.entity().getPurpose(), SearchCriteria.Op.EQ); - accountSearch.and("state", accountSearch.entity().getState(), SearchCriteria.Op.EQ); - accountSearch.and("removed", accountSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - accountSearch.done(); - - // Search by domain - domainSearch = createSearchBuilder(); - domainSearch.and("domainId", domainSearch.entity().getDomainId(), SearchCriteria.Op.EQ); - domainSearch.and("purpose", domainSearch.entity().getPurpose(), SearchCriteria.Op.EQ); - domainSearch.and("state", domainSearch.entity().getState(), SearchCriteria.Op.EQ); - domainSearch.and("removed", domainSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - domainSearch.done(); - - // Search by zone - zoneSearch = createSearchBuilder(); - zoneSearch.and("zoneId", zoneSearch.entity().getZoneId(), SearchCriteria.Op.EQ); - zoneSearch.and("purpose", zoneSearch.entity().getPurpose(), SearchCriteria.Op.EQ); - zoneSearch.and("state", zoneSearch.entity().getState(), SearchCriteria.Op.EQ); - zoneSearch.and("removed", zoneSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - zoneSearch.done(); - - // Search for accessible keys (by account or domain) - accessibleSearch = createSearchBuilder(); - accessibleSearch.and("accountId", accessibleSearch.entity().getAccountId(), SearchCriteria.Op.EQ); - accessibleSearch.and("domainId", accessibleSearch.entity().getDomainId(), SearchCriteria.Op.EQ); - accessibleSearch.and("zoneId", accessibleSearch.entity().getZoneId(), SearchCriteria.Op.EQ); - accessibleSearch.and("purpose", accessibleSearch.entity().getPurpose(), SearchCriteria.Op.EQ); - accessibleSearch.and("state", accessibleSearch.entity().getState(), SearchCriteria.Op.EQ); - accessibleSearch.and("removed", accessibleSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - accessibleSearch.done(); - } - - @Override - public KMSKeyVO findByUuid(String uuid) { - SearchCriteria sc = uuidSearch.create(); - sc.setParameters("uuid", uuid); - return findOneBy(sc); + allFieldSearch = createSearchBuilder(); + allFieldSearch.and("kekLabel", allFieldSearch.entity().getKekLabel(), SearchCriteria.Op.EQ); + allFieldSearch.and("providerName", allFieldSearch.entity().getProviderName(), SearchCriteria.Op.EQ); + allFieldSearch.and("domainId", allFieldSearch.entity().getDomainId(), SearchCriteria.Op.EQ); + allFieldSearch.and("accountId", allFieldSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + allFieldSearch.and("purpose", allFieldSearch.entity().getPurpose(), SearchCriteria.Op.EQ); + allFieldSearch.and("state", allFieldSearch.entity().getState(), SearchCriteria.Op.EQ); + allFieldSearch.and("zoneId", allFieldSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + allFieldSearch.done(); } @Override public KMSKeyVO findByKekLabel(String kekLabel, String providerName) { - SearchCriteria sc = kekLabelSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("kekLabel", kekLabel); sc.setParameters("providerName", providerName); return findOneBy(sc); @@ -112,7 +58,7 @@ public class KMSKeyDaoImpl extends GenericDaoBase implements KMS @Override public List listByAccount(Long accountId, KeyPurpose purpose, KMSKey.State state) { - SearchCriteria sc = accountSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("accountId", accountId); if (purpose != null) { sc.setParameters("purpose", purpose); @@ -123,24 +69,9 @@ public class KMSKeyDaoImpl extends GenericDaoBase implements KMS return listBy(sc); } - @Override - public List listByDomain(Long domainId, KeyPurpose purpose, KMSKey.State state, boolean includeSubdomains) { - SearchCriteria sc = domainSearch.create(); - sc.setParameters("domainId", domainId); - if (purpose != null) { - sc.setParameters("purpose", purpose); - } - if (state != null) { - sc.setParameters("state", state); - } - // TODO: Implement subdomain traversal if includeSubdomains is true - // For now, just return keys in this domain - return listBy(sc); - } - @Override public List listByZone(Long zoneId, KeyPurpose purpose, KMSKey.State state) { - SearchCriteria sc = zoneSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("zoneId", zoneId); if (purpose != null) { sc.setParameters("purpose", purpose); @@ -153,8 +84,7 @@ public class KMSKeyDaoImpl extends GenericDaoBase implements KMS @Override public List listAccessibleKeys(Long accountId, Long domainId, Long zoneId, KeyPurpose purpose, KMSKey.State state) { - SearchCriteria sc = accessibleSearch.create(); - // Keys owned by the account or in the domain + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("accountId", accountId); if (zoneId != null) { sc.setParameters("zoneId", zoneId); @@ -173,17 +103,15 @@ public class KMSKeyDaoImpl extends GenericDaoBase implements KMS if (kmsKeyId == null) { return 0; } - // Delegate to KMSWrappedKeyDao return kmsWrappedKeyDao.countByKmsKeyId(kmsKeyId); } @Override public long countByKekLabel(String kekLabel, String providerName) { - SearchCriteria sc = kekLabelSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("kekLabel", kekLabel); sc.setParameters("providerName", providerName); Integer count = getCount(sc); return count != null ? count.longValue() : 0L; } } - diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java index 09210bcc17c..401c7382f11 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java @@ -29,14 +29,6 @@ import java.util.List; */ public interface KMSWrappedKeyDao extends GenericDao { - /** - * Find a wrapped key by UUID - * - * @param uuid the key UUID - * @return the wrapped key, or null if not found - */ - KMSWrappedKeyVO findByUuid(String uuid); - /** * List all wrapped keys using a specific KMS key * (useful for key rotation) @@ -69,5 +61,13 @@ public interface KMSWrappedKeyDao extends GenericDao { * @return list of wrapped keys */ List listByKekVersionId(Long kekVersionId); -} + /** + * List wrapped keys for a KMS key that need re-encryption (not using specified version) + * + * @param kmsKeyId the KMS key ID + * @param excludeKekVersionId the KEK version ID to exclude (keys using this version don't need rewrap) + * @return list of wrapped keys that need re-encryption + */ + List listWrappedKeysForRewrap(long kmsKeyId, long excludeKekVersionId); +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java index ccd44ac4dac..97db64e054a 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java @@ -25,69 +25,50 @@ import org.springframework.stereotype.Component; import java.util.List; -/** - * Implementation of KMSWrappedKeyDao - */ @Component public class KMSWrappedKeyDaoImpl extends GenericDaoBase implements KMSWrappedKeyDao { - private final SearchBuilder uuidSearch; - private final SearchBuilder kmsKeyIdSearch; - private final SearchBuilder kekVersionIdSearch; - private final SearchBuilder zoneSearch; + private final SearchBuilder allFieldSearch; + private final SearchBuilder rewrapExcludeVersionSearch; public KMSWrappedKeyDaoImpl() { super(); // Search by UUID - uuidSearch = createSearchBuilder(); - uuidSearch.and("uuid", uuidSearch.entity().getUuid(), SearchCriteria.Op.EQ); - uuidSearch.and("removed", uuidSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - uuidSearch.done(); + allFieldSearch = createSearchBuilder(); + allFieldSearch.and("kmsKeyId", allFieldSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); + allFieldSearch.and("kekVersionId", allFieldSearch.entity().getKekVersionId(), SearchCriteria.Op.EQ); + allFieldSearch.and("zoneId", allFieldSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + allFieldSearch.and("kmsKeyId", allFieldSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); + allFieldSearch.done(); - // Search by KMS Key ID (FK to kms_keys) - kmsKeyIdSearch = createSearchBuilder(); - kmsKeyIdSearch.and("kmsKeyId", kmsKeyIdSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); - kmsKeyIdSearch.and("removed", kmsKeyIdSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - kmsKeyIdSearch.done(); - - // Search by KEK Version ID (FK to kms_kek_versions) - kekVersionIdSearch = createSearchBuilder(); - kekVersionIdSearch.and("kekVersionId", kekVersionIdSearch.entity().getKekVersionId(), SearchCriteria.Op.EQ); - kekVersionIdSearch.and("removed", kekVersionIdSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - kekVersionIdSearch.done(); - - // Search by zone - zoneSearch = createSearchBuilder(); - zoneSearch.and("zoneId", zoneSearch.entity().getZoneId(), SearchCriteria.Op.EQ); - zoneSearch.and("removed", zoneSearch.entity().getRemoved(), SearchCriteria.Op.NULL); - zoneSearch.done(); - } - - @Override - public KMSWrappedKeyVO findByUuid(String uuid) { - SearchCriteria sc = uuidSearch.create(); - sc.setParameters("uuid", uuid); - return findOneBy(sc); + // Search builder for excluding specific version using OR condition + rewrapExcludeVersionSearch = createSearchBuilder(); + rewrapExcludeVersionSearch.and("kmsKeyId", rewrapExcludeVersionSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); + // OR group: (kekVersionId != excludeKekVersionId OR kekVersionId IS NULL) + rewrapExcludeVersionSearch.and().op("kekVersionId", rewrapExcludeVersionSearch.entity().getKekVersionId(), SearchCriteria.Op.NEQ); + rewrapExcludeVersionSearch.or("kekVersionIdNull", rewrapExcludeVersionSearch.entity().getKekVersionId(), SearchCriteria.Op.NULL); + rewrapExcludeVersionSearch.cp(); + rewrapExcludeVersionSearch.done(); } @Override public List listByKmsKeyId(Long kmsKeyId) { - SearchCriteria sc = kmsKeyIdSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("kmsKeyId", kmsKeyId); return listBy(sc); } @Override public List listByZone(Long zoneId) { - SearchCriteria sc = zoneSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("zoneId", zoneId); return listBy(sc); } @Override public long countByKmsKeyId(Long kmsKeyId) { - SearchCriteria sc = kmsKeyIdSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("kmsKeyId", kmsKeyId); Integer count = getCount(sc); return count != null ? count.longValue() : 0L; @@ -95,9 +76,16 @@ public class KMSWrappedKeyDaoImpl extends GenericDaoBase @Override public List listByKekVersionId(Long kekVersionId) { - SearchCriteria sc = kekVersionIdSearch.create(); + SearchCriteria sc = allFieldSearch.create(); sc.setParameters("kekVersionId", kekVersionId); return listBy(sc); } -} + @Override + public List listWrappedKeysForRewrap(long kmsKeyId, long excludeKekVersionId) { + SearchCriteria sc = rewrapExcludeVersionSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + sc.setParameters("kekVersionId", excludeKekVersionId); + return listBy(sc); + } +} diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index ff5651ba38f..e7dde87c99f 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -185,7 +185,55 @@ CREATE TABLE IF NOT EXISTS `cloud`.`kms_wrapped_key` ( CONSTRAINT `fk_kms_wrapped_key__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KMS wrapped encryption keys (DEKs) - references kms_keys for KEK metadata and kek_versions for specific version'; +-- Add KMS key reference to volumes table (which KMS key was used) +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.volumes', 'kms_key_id', 'BIGINT UNSIGNED COMMENT ''KMS key ID used for volume encryption'''); +CALL `cloud`.`IDEMPOTENT_ADD_FOREIGN_KEY`('cloud.volumes', 'fk_volumes__kms_key_id', '(kms_key_id)', '`kms_keys`(`id`)'); + -- Add KMS wrapped key reference to volumes table CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.volumes', 'kms_wrapped_key_id', 'BIGINT UNSIGNED COMMENT ''KMS wrapped key ID for volume encryption'''); CALL `cloud`.`IDEMPOTENT_ADD_FOREIGN_KEY`('cloud.volumes', 'fk_volumes__kms_wrapped_key_id', '(kms_wrapped_key_id)', '`kms_wrapped_key`(`id`)'); +-- KMS Database Provider KEK Objects (PKCS#11-like object storage) +-- Stores KEKs for the database KMS provider in a PKCS#11-compatible format +CREATE TABLE IF NOT EXISTS `cloud`.`kms_database_kek_objects` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Object handle (PKCS#11 CKA_HANDLE)', + `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID', + -- PKCS#11 Object Class (CKA_CLASS) + `object_class` VARCHAR(32) NOT NULL DEFAULT 'CKO_SECRET_KEY' COMMENT 'PKCS#11 object class (CKO_SECRET_KEY, CKO_PRIVATE_KEY, etc.)', + -- PKCS#11 Label (CKA_LABEL) - human-readable identifier + `label` VARCHAR(255) NOT NULL COMMENT 'PKCS#11 label (CKA_LABEL) - human-readable identifier', + -- PKCS#11 ID (CKA_ID) - application-defined identifier + `object_id` VARBINARY(64) COMMENT 'PKCS#11 object ID (CKA_ID) - application-defined identifier', + -- Key Type (CKA_KEY_TYPE) + `key_type` VARCHAR(32) NOT NULL DEFAULT 'CKK_AES' COMMENT 'PKCS#11 key type (CKK_AES, CKK_RSA, etc.)', + -- Key Material (CKA_VALUE) - encrypted KEK material + `key_material` VARBINARY(512) NOT NULL COMMENT 'PKCS#11 key value (CKA_VALUE) - encrypted KEK material', + -- Key Attributes (PKCS#11 boolean attributes) + `is_sensitive` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_SENSITIVE - key material is sensitive', + `is_extractable` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'PKCS#11 CKA_EXTRACTABLE - key can be extracted', + `is_token` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_TOKEN - object is on token (persistent)', + `is_private` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_PRIVATE - object is private', + `is_modifiable` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'PKCS#11 CKA_MODIFIABLE - object can be modified', + `is_copyable` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'PKCS#11 CKA_COPYABLE - object can be copied', + `is_destroyable` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_DESTROYABLE - object can be destroyed', + `always_sensitive` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_ALWAYS_SENSITIVE - key was always sensitive', + `never_extractable` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_NEVER_EXTRACTABLE - key was never extractable', + -- Key Metadata + `purpose` VARCHAR(32) NOT NULL COMMENT 'Key purpose (VOLUME_ENCRYPTION, TLS_CERT, CONFIG_SECRET)', + `key_bits` INT NOT NULL COMMENT 'Key size in bits (128, 192, 256)', + `algorithm` VARCHAR(64) NOT NULL DEFAULT 'AES/GCM/NoPadding' COMMENT 'Encryption algorithm', + -- Validity Dates (PKCS#11 CKA_START_DATE, CKA_END_DATE) + `start_date` DATETIME COMMENT 'PKCS#11 CKA_START_DATE - key validity start', + `end_date` DATETIME COMMENT 'PKCS#11 CKA_END_DATE - key validity end', + -- Lifecycle + `created` DATETIME NOT NULL COMMENT 'Creation timestamp', + `last_used` DATETIME COMMENT 'Last usage timestamp', + `removed` DATETIME COMMENT 'Removal timestamp for soft delete', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_uuid` (`uuid`), + UNIQUE KEY `uk_label_removed` (`label`, `removed`), + INDEX `idx_purpose_removed` (`purpose`, `removed`), + INDEX `idx_key_type` (`key_type`, `removed`), + INDEX `idx_object_class` (`object_class`, `removed`), + INDEX `idx_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KMS Database Provider KEK Objects - PKCS#11-like object storage for KEKs'; diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java index 43218b3f6a0..2a7b286aaf2 100644 --- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java +++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java @@ -46,6 +46,8 @@ import org.apache.cloudstack.engine.subsystem.api.storage.DataObjectInStore; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; import org.apache.cloudstack.engine.subsystem.api.storage.ObjectInDataStoreStateMachine; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.kms.KMSManager; +import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; import org.apache.cloudstack.storage.command.CopyCmdAnswer; import org.apache.cloudstack.storage.command.CreateObjectAnswer; import org.apache.cloudstack.storage.datastore.ObjectInDataStoreManager; @@ -98,6 +100,10 @@ public class VolumeObject implements VolumeInfo { @Inject VolumeDataStoreDao volumeStoreDao; @Inject + KMSManager kmsManager; + @Inject + KMSWrappedKeyDao kmsWrappedKeyDao; + @Inject ObjectInDataStoreManager objectInStoreMgr; @Inject ResourceLimitService resourceLimitMgr; @@ -900,6 +906,26 @@ public class VolumeObject implements VolumeInfo { volumeVO.setPassphraseId(id); } + @Override + public Long getKmsKeyId() { + return volumeVO.getKmsKeyId(); + } + + @Override + public void setKmsKeyId(Long id) { + volumeVO.setKmsKeyId(id); + } + + @Override + public Long getKmsWrappedKeyId() { + return volumeVO.getKmsWrappedKeyId(); + } + + @Override + public void setKmsWrappedKeyId(Long id) { + volumeVO.setKmsWrappedKeyId(id); + } + /** * Removes passphrase reference from underlying volume. Also removes the associated passphrase entry if it is the last user. */ @@ -929,9 +955,21 @@ public class VolumeObject implements VolumeInfo { /** * Looks up passphrase from underlying volume. - * @return passphrase as bytes + * Supports both legacy passphrase-based encryption and KMS-based encryption. + * @return passphrase/DEK as bytes */ public byte[] getPassphrase() { + // First check for KMS-encrypted volume + if (volumeVO.getKmsWrappedKeyId() != null) { + try { + return kmsManager.unwrapKey(volumeVO.getKmsWrappedKeyId()); + } catch (org.apache.cloudstack.framework.kms.KMSException e) { + logger.error("Failed to unwrap KMS key for volume {}: {}", volumeVO.getId(), e.getMessage()); + return new byte[0]; + } + } + + // Fallback to legacy passphrase-based encryption PassphraseVO passphrase = passphraseDao.findById(volumeVO.getPassphraseId()); if (passphrase != null) { return passphrase.getPassphrase(); diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java index 58b8d251a57..59af8f5f6a6 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 @@ -176,4 +176,3 @@ public class KMSException extends CloudRuntimeException { return errorType.isRetryable(); } } - diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java index cfee06f6278..7ab881de1cf 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 @@ -19,6 +19,8 @@ package org.apache.cloudstack.framework.kms; import org.apache.cloudstack.framework.config.Configurable; +import com.cloud.utils.component.Adapter; + import java.util.List; /** @@ -35,7 +37,7 @@ import java.util.List; *

* Thread-safety: Implementations must be thread-safe for concurrent operations. */ -public interface KMSProvider extends Configurable { +public interface KMSProvider extends Configurable, Adapter { /** * Get the unique name of this provider @@ -141,4 +143,3 @@ public interface KMSProvider extends Configurable { */ boolean healthCheck() throws KMSException; } - diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSService.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSService.java deleted file mode 100644 index d9dc14ea8ca..00000000000 --- a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSService.java +++ /dev/null @@ -1,166 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -package org.apache.cloudstack.framework.kms; - -import java.util.List; - -/** - * High-level service interface for Key Management Service operations. - *

- * This facade abstracts provider-specific details and provides zone-aware - * routing, retry logic, and audit logging for KMS operations. - *

- * The service handles: - * - Zone-scoped provider selection - * - Configuration management (which provider, which KEK) - * - Retry logic for transient failures - * - Audit event emission - * - Health monitoring - */ -public interface KMSService { - - /** - * Get the service name - * - * @return service name - */ - String getName(); - - // ==================== Provider Management ==================== - - /** - * List all registered KMS providers - * - * @return list of available providers - */ - List listProviders(); - - /** - * Get a specific provider by name - * - * @param name provider name - * @return the provider, or null if not found - */ - KMSProvider getProvider(String name); - - /** - * Get the configured provider for a specific zone. - * Falls back to global default if zone has no specific configuration. - * - * @param zoneId the zone ID (null for global) - * @return the configured provider for the zone - * @throws KMSException if no provider configured or provider not found - */ - KMSProvider getProviderForZone(Long zoneId) throws KMSException; - - // ==================== KEK Management ==================== - - /** - * Create a new KEK for a specific zone and purpose - * - * @param zoneId the zone ID (null for global) - * @param purpose the purpose of the KEK - * @param label optional custom label (null for auto-generated) - * @param keyBits key size in bits - * @return the KEK identifier - * @throws KMSException if creation fails - */ - String createKek(Long zoneId, KeyPurpose purpose, String label, int keyBits) throws KMSException; - - /** - * Delete a KEK (use with extreme caution!) - * - * @param zoneId the zone ID - * @param kekId the KEK identifier to delete - * @throws KMSException if deletion fails - */ - void deleteKek(Long zoneId, String kekId) throws KMSException; - - /** - * List KEKs for a zone and purpose - * - * @param zoneId the zone ID (null for all zones) - * @param purpose the purpose filter (null for all purposes) - * @return list of KEK identifiers - * @throws KMSException if listing fails - */ - List listKeks(Long zoneId, KeyPurpose purpose) throws KMSException; - - /** - * Check if a KEK is available in a zone - * - * @param zoneId the zone ID - * @param kekId the KEK identifier - * @return true if available - * @throws KMSException if check fails - */ - boolean isKekAvailable(Long zoneId, String kekId) throws KMSException; - - /** - * Rotate a KEK by creating a new one and rewrapping all associated DEKs. - * This is an async operation that may take time for large deployments. - * - * @param zoneId the zone ID - * @param purpose the purpose of keys to rotate - * @param oldKekLabel the current KEK label (null for configured default) - * @param newKekLabel the new KEK label (null for auto-generated) - * @param keyBits the new KEK size in bits - * @return the new KEK identifier - * @throws KMSException if rotation fails - */ - String rotateKek(Long zoneId, KeyPurpose purpose, String oldKekLabel, - String newKekLabel, int keyBits) throws KMSException; - - // ==================== DEK Operations ==================== - - /** - * Generate and wrap a new DEK for volume encryption - * - * @param zoneId the zone ID where the volume resides - * @param purpose the key purpose (typically VOLUME_ENCRYPTION) - * @param kekLabel the KEK label to use (null for configured default) - * @param keyBits DEK size in bits - * @return wrapped key ready for database storage - * @throws KMSException if operation fails - */ - WrappedKey generateAndWrapDek(Long zoneId, KeyPurpose purpose, - String kekLabel, int keyBits) throws KMSException; - - /** - * Unwrap a DEK for use (e.g., attaching encrypted volume) - *

- * SECURITY: Caller must zeroize the returned byte array after use - * - * @param wrappedKey the wrapped key from database - * @return plaintext DEK (caller must zeroize!) - * @throws KMSException if unwrap fails - */ - byte[] unwrapDek(WrappedKey wrappedKey) throws KMSException; - - // ==================== Health & Status ==================== - - /** - * Check health of KMS provider for a zone - * - * @param zoneId the zone ID (null for global check) - * @return true if healthy - * @throws KMSException if health check fails critically - */ - boolean healthCheck(Long zoneId) throws KMSException; -} - diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java index 7cbd544f4c7..cea182eb75e 100644 --- a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java @@ -30,12 +30,7 @@ public enum KeyPurpose { /** * Keys used for protecting TLS certificate private keys */ - TLS_CERT("tls", "TLS certificate private keys"), - - /** - * Keys used for encrypting configuration secrets and sensitive settings - */ - CONFIG_SECRET("config", "Configuration secrets"); + TLS_CERT("tls", "TLS certificate private keys"); private final String name; private final String description; @@ -79,4 +74,3 @@ public enum KeyPurpose { return name + "-kek-" + (customLabel != null ? customLabel : "v1"); } } - diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java index fccf45119e7..e70c5e32c46 100644 --- a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java @@ -32,7 +32,7 @@ import java.util.Objects; * - Wrapped Key: DEK encrypted by KEK, safe to store in database */ public class WrappedKey { - private final String id; + private final String uuid; private final String kekId; private final KeyPurpose purpose; private final String algorithm; @@ -55,29 +55,16 @@ public class WrappedKey { public WrappedKey(String kekId, KeyPurpose purpose, String algorithm, byte[] wrappedKeyMaterial, String providerName, Date created, Long zoneId) { - this.id = null; // Will be set when persisted to DB - this.kekId = Objects.requireNonNull(kekId, "kekId cannot be null"); - this.purpose = Objects.requireNonNull(purpose, "purpose cannot be null"); - this.algorithm = Objects.requireNonNull(algorithm, "algorithm cannot be null"); - this.providerName = providerName; - - // Defensive copy to prevent external modification - if (wrappedKeyMaterial == null || wrappedKeyMaterial.length == 0) { - throw new IllegalArgumentException("wrappedKeyMaterial cannot be null or empty"); - } - this.wrappedKeyMaterial = Arrays.copyOf(wrappedKeyMaterial, wrappedKeyMaterial.length); - - this.created = created != null ? new Date(created.getTime()) : new Date(); - this.zoneId = zoneId; + this(null, kekId, purpose, algorithm, wrappedKeyMaterial, providerName, created, zoneId); } /** * Constructor for database-loaded keys with ID */ - public WrappedKey(String id, String kekId, KeyPurpose purpose, String algorithm, + public WrappedKey(String uuid, String kekId, KeyPurpose purpose, String algorithm, byte[] wrappedKeyMaterial, String providerName, Date created, Long zoneId) { - this.id = id; + this.uuid = uuid; this.kekId = Objects.requireNonNull(kekId, "kekId cannot be null"); this.purpose = Objects.requireNonNull(purpose, "purpose cannot be null"); this.algorithm = Objects.requireNonNull(algorithm, "algorithm cannot be null"); @@ -92,8 +79,8 @@ public class WrappedKey { this.zoneId = zoneId; } - public String getId() { - return id; + public String getUuid() { + return uuid; } public String getKekId() { @@ -128,30 +115,10 @@ public class WrappedKey { return zoneId; } - @Override - public int hashCode() { - int result = Objects.hash(id, kekId, purpose, algorithm, providerName); - result = 31 * result + Arrays.hashCode(wrappedKeyMaterial); - return result; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - WrappedKey that = (WrappedKey) o; - return Objects.equals(id, that.id) && - Objects.equals(kekId, that.kekId) && - purpose == that.purpose && - Objects.equals(algorithm, that.algorithm) && - Arrays.equals(wrappedKeyMaterial, that.wrappedKeyMaterial) && - Objects.equals(providerName, that.providerName); - } - @Override public String toString() { return "WrappedKey{" + - "id='" + id + '\'' + + "uuid='" + uuid + '\'' + ", kekId='" + kekId + '\'' + ", purpose=" + purpose + ", algorithm='" + algorithm + '\'' + @@ -162,4 +129,3 @@ public class WrappedKey { '}'; } } - diff --git a/plugins/kms/database/pom.xml b/plugins/kms/database/pom.xml index 1a2c9271d02..2bbeb2dc75b 100644 --- a/plugins/kms/database/pom.xml +++ b/plugins/kms/database/pom.xml @@ -17,8 +17,8 @@ specific language governing permissions and limitations under the License. --> - 4.0.0 cloud-plugin-kms-database 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 aab86660657..30736a59456 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 @@ -17,22 +17,26 @@ package org.apache.cloudstack.kms.provider; +import com.cloud.utils.component.AdapterBase; import com.google.crypto.tink.subtle.AesGcmJce; import org.apache.cloudstack.framework.config.ConfigKey; -import org.apache.cloudstack.framework.config.dao.ConfigurationDao; -import org.apache.cloudstack.framework.config.impl.ConfigurationVO; 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.provider.database.KMSDatabaseKekObjectVO; +import org.apache.cloudstack.kms.provider.database.dao.KMSDatabaseKekObjectDao; +import com.cloud.utils.crypt.DBEncryptionUtil; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import javax.inject.Inject; +import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.Date; import java.util.List; import java.util.Map; @@ -40,13 +44,14 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; /** - * Database-backed KMS provider that stores master KEKs encrypted in the configuration table. + * Database-backed KMS provider that stores master KEKs in a PKCS#11-like object table. * Uses AES-256-GCM for all cryptographic operations. *

* This provider is suitable for deployments that don't have access to HSM hardware. - * The master KEKs are stored encrypted using CloudStack's existing DBEncryptionUtil. + * The master KEKs are stored encrypted in the kms_database_kek_objects table using + * CloudStack's existing DBEncryptionUtil, with PKCS#11-compatible attributes. */ -public class DatabaseKMSProvider implements KMSProvider { +public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { // Configuration keys public static final ConfigKey CacheEnabled = new ConfigKey<>( "Advanced", @@ -59,15 +64,17 @@ public class DatabaseKMSProvider implements KMSProvider { ); private static final Logger logger = LogManager.getLogger(DatabaseKMSProvider.class); private static final String PROVIDER_NAME = "database"; - private static final String KEK_CONFIG_PREFIX = "kms.database.kek."; private static final int GCM_IV_LENGTH = 12; // 96 bits recommended for GCM private static final int GCM_TAG_LENGTH = 16; // 128 bits private static final String ALGORITHM = "AES/GCM/NoPadding"; + // PKCS#11 constants + private static final String CKO_SECRET_KEY = "CKO_SECRET_KEY"; + private static final String CKK_AES = "CKK_AES"; // In-memory cache of KEKs (encrypted form cached, decrypted on demand) private final Map kekCache = new ConcurrentHashMap<>(); private final SecureRandom secureRandom = new SecureRandom(); @Inject - private ConfigurationDao configDao; + private KMSDatabaseKekObjectDao kekObjectDao; @Override public String getProviderName() { @@ -84,11 +91,8 @@ public class DatabaseKMSProvider implements KMSProvider { label = generateKekLabel(purpose); } - String configKey = buildConfigKey(label); - // Check if KEK already exists - ConfigurationVO existing = configDao.findByName(configKey); - if (existing != null) { + if (kekObjectDao.existsByLabel(label)) { throw KMSException.keyAlreadyExists("KEK with label " + label + " already exists"); } @@ -97,24 +101,36 @@ public class DatabaseKMSProvider implements KMSProvider { byte[] kekBytes = new byte[keyBits / 8]; secureRandom.nextBytes(kekBytes); - // Store in configuration table (will be encrypted automatically due to "Secure" category) - String kekBase64 = java.util.Base64.getEncoder().encodeToString(kekBytes); - ConfigurationVO config = new ConfigurationVO( - "Secure", // Category - triggers encryption - "DEFAULT", - getConfigComponentName(), - configKey, - kekBase64, - "KMS KEK for " + purpose.getName() + " (label: " + label + ")" - ); - configDao.persist(config); + // Encrypt the KEK material using DBEncryptionUtil (Base64 encode first, then encrypt) + String kekBase64 = Base64.getEncoder().encodeToString(kekBytes); + String encryptedKek = DBEncryptionUtil.encrypt(kekBase64); + byte[] encryptedKekBytes = encryptedKek.getBytes(StandardCharsets.UTF_8); + + // Create PKCS#11-like object + KMSDatabaseKekObjectVO kekObject = new KMSDatabaseKekObjectVO(label, purpose, keyBits, encryptedKekBytes); + kekObject.setObjectClass(CKO_SECRET_KEY); + kekObject.setKeyType(CKK_AES); + kekObject.setObjectId(label.getBytes(StandardCharsets.UTF_8)); + kekObject.setAlgorithm(ALGORITHM); + // PKCS#11 attributes for KEK + kekObject.setIsSensitive(true); + kekObject.setIsExtractable(false); + kekObject.setIsToken(true); + kekObject.setIsPrivate(true); + kekObject.setIsModifiable(false); + kekObject.setIsCopyable(false); + kekObject.setIsDestroyable(true); + kekObject.setAlwaysSensitive(true); + kekObject.setNeverExtractable(true); + + kekObjectDao.persist(kekObject); // Cache the KEK if (CacheEnabled.value()) { kekCache.put(label, kekBytes); } - logger.info("Created KEK with label {} for purpose {}", label, purpose); + logger.info("Created KEK with label {} for purpose {} (PKCS#11 object ID: {})", label, purpose, kekObject.getId()); return label; } catch (Exception e) { @@ -136,16 +152,13 @@ public class DatabaseKMSProvider implements KMSProvider { @Override public void deleteKek(String kekId) throws KMSException { - String configKey = buildConfigKey(kekId); - - ConfigurationVO config = configDao.findByName(configKey); - if (config == null) { + KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekId); + if (kekObject == null) { throw KMSException.kekNotFound("KEK with label " + kekId + " not found"); } try { - // Remove from configuration (name is the primary key) - configDao.remove(config.getName()); + kekObjectDao.remove(kekObject.getId()); // Remove from cache byte[] cachedKek = kekCache.remove(kekId); @@ -153,8 +166,12 @@ public class DatabaseKMSProvider implements KMSProvider { Arrays.fill(cachedKek, (byte) 0); // Zeroize } - logger.warn("Deleted KEK with label {}. All DEKs wrapped with this KEK are now unrecoverable!", kekId); + // Zeroize key material in database object + if (kekObject.getKeyMaterial() != null) { + Arrays.fill(kekObject.getKeyMaterial(), (byte) 0); + } + logger.warn("Deleted KEK with label {}. All DEKs wrapped with this KEK are now unrecoverable!", kekId); } catch (Exception e) { throw KMSException.kekOperationFailed("Failed to delete KEK: " + e.getMessage(), e); } @@ -165,18 +182,20 @@ public class DatabaseKMSProvider implements KMSProvider { try { List keks = new ArrayList<>(); - // We can't efficiently list all KEKs without a custom query - // For now, return cached keys only - KEKs will be tracked via cache - // TODO: Add custom DAO method or maintain KEK registry - logger.debug("listKeks called for purpose: {}. Returning cached keys only.", purpose); + List kekObjects; + if (purpose != null) { + kekObjects = kekObjectDao.listByPurpose(purpose); + } else { + kekObjects = kekObjectDao.listAll(); + } - // Return keys from cache - for (String label : kekCache.keySet()) { - if (purpose == null || label.startsWith(purpose.getName())) { - keks.add(label); + 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); @@ -186,9 +205,8 @@ public class DatabaseKMSProvider implements KMSProvider { @Override public boolean isKekAvailable(String kekId) throws KMSException { try { - String configKey = buildConfigKey(kekId); - ConfigurationVO config = configDao.findByName(configKey); - return config != null && config.getValue() != null; + KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekId); + return kekObject != null && kekObject.getRemoved() == null && kekObject.getKeyMaterial() != null; } catch (Exception e) { logger.warn("Error checking KEK availability: {}", e.getMessage()); return false; @@ -211,19 +229,10 @@ public class DatabaseKMSProvider implements KMSProvider { // Encrypt the DEK (Tink's encrypt returns [IV][ciphertext+tag] format) byte[] wrappedBlob = aesgcm.encrypt(plainKey, new byte[0]); // Empty associated data - WrappedKey wrapped = new WrappedKey( - kekLabel, - purpose, - ALGORITHM, - wrappedBlob, - PROVIDER_NAME, - new Date(), - null // zoneId set by caller - ); + WrappedKey wrapped = new WrappedKey(kekLabel, purpose, ALGORITHM, wrappedBlob, PROVIDER_NAME, new Date(), null); logger.debug("Wrapped {} key with KEK {}", purpose, kekLabel); return wrapped; - } catch (Exception e) { throw KMSException.wrapUnwrapFailed("Failed to wrap key: " + e.getMessage(), e); } finally { @@ -302,36 +311,11 @@ public class DatabaseKMSProvider implements KMSProvider { @Override public boolean healthCheck() throws KMSException { try { - // Verify we can access configuration - if (configDao == null) { - logger.error("Configuration DAO is not initialized"); + // Verify we can access KEK object DAO + if (kekObjectDao == null) { + logger.error("KMSDatabaseKekObjectDao is not initialized"); return false; } - - // Try to list KEKs (lightweight operation) - List keks = listKeks(null); - logger.debug("Health check passed. Found {} KEKs", keks.size()); - - // Optionally verify we can perform wrap/unwrap - byte[] testKey = new byte[32]; - secureRandom.nextBytes(testKey); - - // If we have any KEK, test it - if (!keks.isEmpty()) { - String testKek = keks.get(0); - WrappedKey wrapped = wrapKey(testKey, KeyPurpose.VOLUME_ENCRYPTION, testKek); - byte[] unwrapped = unwrapKey(wrapped); - - boolean matches = Arrays.equals(testKey, unwrapped); - Arrays.fill(unwrapped, (byte) 0); - - if (!matches) { - logger.error("Health check failed: wrap/unwrap test failed"); - return false; - } - } - - Arrays.fill(testKey, (byte) 0); return true; } catch (Exception e) { @@ -339,52 +323,65 @@ public class DatabaseKMSProvider implements KMSProvider { } } - // ==================== Private Helper Methods ==================== - private byte[] loadKek(String kekLabel) throws KMSException { // Check cache first if (CacheEnabled.value()) { byte[] cached = kekCache.get(kekLabel); if (cached != null) { + updateLastUsed(kekLabel); return Arrays.copyOf(cached, cached.length); // Return copy } } // Load from database - String configKey = buildConfigKey(kekLabel); - ConfigurationVO config = configDao.findByName(configKey); + KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekLabel); - if (config == null) { + if (kekObject == null || kekObject.getRemoved() != null) { throw KMSException.kekNotFound("KEK with label " + kekLabel + " not found"); } try { - // getValue() automatically decrypts - String kekBase64 = config.getValue(); - if (StringUtils.isEmpty(kekBase64)) { + // Decrypt the key material + byte[] encryptedKekBytes = kekObject.getKeyMaterial(); + if (encryptedKekBytes == null || encryptedKekBytes.length == 0) { throw KMSException.kekNotFound("KEK value is empty for label " + kekLabel); } - byte[] kekBytes = java.util.Base64.getDecoder().decode(kekBase64); + // Decrypt using DBEncryptionUtil + String encryptedKek = new String(encryptedKekBytes, StandardCharsets.UTF_8); + String kekBase64 = DBEncryptionUtil.decrypt(encryptedKek); + byte[] kekBytes = Base64.getDecoder().decode(kekBase64); // Cache for future use if (CacheEnabled.value()) { kekCache.put(kekLabel, Arrays.copyOf(kekBytes, kekBytes.length)); } + // Update last used timestamp + updateLastUsed(kekLabel); + return kekBytes; } catch (IllegalArgumentException e) { throw KMSException.kekOperationFailed("Invalid KEK encoding for label " + kekLabel, e); + } catch (Exception e) { + throw KMSException.kekOperationFailed("Failed to decrypt KEK for label " + kekLabel + ": " + e.getMessage(), e); } } - private String buildConfigKey(String label) { - return KEK_CONFIG_PREFIX + label; + private void updateLastUsed(String kekLabel) { + try { + KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekLabel); + if (kekObject != null && kekObject.getRemoved() == null) { + kekObject.setLastUsed(new Date()); + kekObjectDao.update(kekObject.getId(), kekObject); + } + } catch (Exception e) { + logger.debug("Failed to update last used timestamp for KEK {}: {}", kekLabel, e.getMessage()); + } } private String generateKekLabel(KeyPurpose purpose) { return purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); } } - diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java new file mode 100644 index 00000000000..e598a2b0914 --- /dev/null +++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java @@ -0,0 +1,357 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms.provider.database; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +/** + * Database entity for KEK objects stored by the database KMS provider. + * Models PKCS#11 object attributes for cryptographic key storage. + *

+ * This table stores KEKs (Key Encryption Keys) in a PKCS#11-compatible format, + * allowing the database provider to mock PKCS#11 interface behavior. + */ +@Entity +@Table(name = "kms_database_kek_objects") +public class KMSDatabaseKekObjectVO { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "uuid", nullable = false, unique = true) + private String uuid; + + // PKCS#11 Object Class (CKA_CLASS) + @Column(name = "object_class", nullable = false, length = 32) + private String objectClass = "CKO_SECRET_KEY"; + + // PKCS#11 Label (CKA_LABEL) - human-readable identifier + @Column(name = "label", nullable = false, length = 255) + private String label; + + // PKCS#11 ID (CKA_ID) - application-defined identifier + @Column(name = "object_id", length = 64) + private byte[] objectId; + + // PKCS#11 Key Type (CKA_KEY_TYPE) + @Column(name = "key_type", nullable = false, length = 32) + private String keyType = "CKK_AES"; + + // PKCS#11 Key Value (CKA_VALUE) - encrypted KEK material + @Column(name = "key_material", nullable = false, length = 512) + private byte[] keyMaterial; + + // PKCS#11 Boolean Attributes + @Column(name = "is_sensitive", nullable = false) + private Boolean isSensitive = true; + + @Column(name = "is_extractable", nullable = false) + private Boolean isExtractable = false; + + @Column(name = "is_token", nullable = false) + private Boolean isToken = true; + + @Column(name = "is_private", nullable = false) + private Boolean isPrivate = true; + + @Column(name = "is_modifiable", nullable = false) + private Boolean isModifiable = false; + + @Column(name = "is_copyable", nullable = false) + private Boolean isCopyable = false; + + @Column(name = "is_destroyable", nullable = false) + private Boolean isDestroyable = true; + + @Column(name = "always_sensitive", nullable = false) + private Boolean alwaysSensitive = true; + + @Column(name = "never_extractable", nullable = false) + private Boolean neverExtractable = true; + + // Key Metadata + @Column(name = "purpose", nullable = false, length = 32) + @Enumerated(EnumType.STRING) + private KeyPurpose purpose; + + @Column(name = "key_bits", nullable = false) + private Integer keyBits; + + @Column(name = "algorithm", nullable = false, length = 64) + private String algorithm = "AES/GCM/NoPadding"; + + // PKCS#11 Validity Dates + @Column(name = "start_date") + @Temporal(TemporalType.TIMESTAMP) + private Date startDate; + + @Column(name = "end_date") + @Temporal(TemporalType.TIMESTAMP) + private Date endDate; + + // Lifecycle + @Column(name = GenericDao.CREATED_COLUMN, nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "last_used") + @Temporal(TemporalType.TIMESTAMP) + private Date lastUsed; + + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(TemporalType.TIMESTAMP) + private Date removed; + + public KMSDatabaseKekObjectVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + } + + /** + * Constructor for creating a new KEK object + * + * @param label PKCS#11 label (CKA_LABEL) + * @param purpose key purpose + * @param keyBits key size in bits + * @param keyMaterial encrypted key material (CKA_VALUE) + */ + public KMSDatabaseKekObjectVO(String label, KeyPurpose purpose, Integer keyBits, byte[] keyMaterial) { + this(); + this.label = label; + this.purpose = purpose; + this.keyBits = keyBits; + this.keyMaterial = keyMaterial; + this.objectId = label.getBytes(); // Use label as object ID by default + this.startDate = new Date(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getObjectClass() { + return objectClass; + } + + public void setObjectClass(String objectClass) { + this.objectClass = objectClass; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public byte[] getObjectId() { + return objectId; + } + + public void setObjectId(byte[] objectId) { + this.objectId = objectId; + } + + public String getKeyType() { + return keyType; + } + + public void setKeyType(String keyType) { + this.keyType = keyType; + } + + public byte[] getKeyMaterial() { + return keyMaterial; + } + + public void setKeyMaterial(byte[] keyMaterial) { + this.keyMaterial = keyMaterial; + } + + public Boolean getIsSensitive() { + return isSensitive; + } + + public void setIsSensitive(Boolean isSensitive) { + this.isSensitive = isSensitive; + } + + public Boolean getIsExtractable() { + return isExtractable; + } + + public void setIsExtractable(Boolean isExtractable) { + this.isExtractable = isExtractable; + } + + public Boolean getIsToken() { + return isToken; + } + + public void setIsToken(Boolean isToken) { + this.isToken = isToken; + } + + public Boolean getIsPrivate() { + return isPrivate; + } + + public void setIsPrivate(Boolean isPrivate) { + this.isPrivate = isPrivate; + } + + public Boolean getIsModifiable() { + return isModifiable; + } + + public void setIsModifiable(Boolean isModifiable) { + this.isModifiable = isModifiable; + } + + public Boolean getIsCopyable() { + return isCopyable; + } + + public void setIsCopyable(Boolean isCopyable) { + this.isCopyable = isCopyable; + } + + public Boolean getIsDestroyable() { + return isDestroyable; + } + + public void setIsDestroyable(Boolean isDestroyable) { + this.isDestroyable = isDestroyable; + } + + public Boolean getAlwaysSensitive() { + return alwaysSensitive; + } + + public void setAlwaysSensitive(Boolean alwaysSensitive) { + this.alwaysSensitive = alwaysSensitive; + } + + public Boolean getNeverExtractable() { + return neverExtractable; + } + + public void setNeverExtractable(Boolean neverExtractable) { + this.neverExtractable = neverExtractable; + } + + public KeyPurpose getPurpose() { + return purpose; + } + + public void setPurpose(KeyPurpose purpose) { + this.purpose = purpose; + } + + public Integer getKeyBits() { + return keyBits; + } + + public void setKeyBits(Integer keyBits) { + this.keyBits = keyBits; + } + + public String getAlgorithm() { + return algorithm; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public Date getStartDate() { + return startDate; + } + + public void setStartDate(Date startDate) { + this.startDate = startDate; + } + + public Date getEndDate() { + return endDate; + } + + public void setEndDate(Date endDate) { + this.endDate = endDate; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getLastUsed() { + return lastUsed; + } + + public void setLastUsed(Date lastUsed) { + this.lastUsed = lastUsed; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + @Override + public String toString() { + return String.format("KMSDatabaseKekObject %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields( + this, "id", "uuid", "label", "purpose", "keyBits", "objectClass", "keyType", "algorithm")); + } +} diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDao.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDao.java new file mode 100644 index 00000000000..582c1179ec4 --- /dev/null +++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDao.java @@ -0,0 +1,61 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms.provider.database.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.provider.database.KMSDatabaseKekObjectVO; + +import java.util.List; + +/** + * DAO for KMSDatabaseKekObject entities + * Provides PKCS#11-like object storage operations for KEKs + */ +public interface KMSDatabaseKekObjectDao extends GenericDao { + + /** + * Find a KEK object by label (PKCS#11 CKA_LABEL) + */ + KMSDatabaseKekObjectVO findByLabel(String label); + + /** + * Find a KEK object by object ID (PKCS#11 CKA_ID) + */ + KMSDatabaseKekObjectVO findByObjectId(byte[] objectId); + + /** + * List all KEK objects by purpose + */ + List listByPurpose(KeyPurpose purpose); + + /** + * List all KEK objects by key type (PKCS#11 CKA_KEY_TYPE) + */ + List listByKeyType(String keyType); + + /** + * List all KEK objects by object class (PKCS#11 CKA_CLASS) + */ + List listByObjectClass(String objectClass); + + /** + * Check if a KEK object exists with the given label + */ + boolean existsByLabel(String label); +} diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDaoImpl.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDaoImpl.java new file mode 100644 index 00000000000..ae65f3248b3 --- /dev/null +++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDaoImpl.java @@ -0,0 +1,84 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms.provider.database.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.provider.database.KMSDatabaseKekObjectVO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class KMSDatabaseKekObjectDaoImpl extends GenericDaoBase implements KMSDatabaseKekObjectDao { + + private final SearchBuilder allFieldSearch; + + public KMSDatabaseKekObjectDaoImpl() { + allFieldSearch = createSearchBuilder(); + allFieldSearch.and("uuid", allFieldSearch.entity().getUuid(), SearchCriteria.Op.EQ); + allFieldSearch.and("label", allFieldSearch.entity().getLabel(), SearchCriteria.Op.EQ); + allFieldSearch.and("objectId", allFieldSearch.entity().getObjectId(), SearchCriteria.Op.EQ); + allFieldSearch.and("purpose", allFieldSearch.entity().getPurpose(), SearchCriteria.Op.EQ); + allFieldSearch.and("keyType", allFieldSearch.entity().getKeyType(), SearchCriteria.Op.EQ); + allFieldSearch.and("objectClass", allFieldSearch.entity().getObjectClass(), SearchCriteria.Op.EQ); + allFieldSearch.done(); + } + + @Override + public KMSDatabaseKekObjectVO findByLabel(String label) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("label", label); + return findOneBy(sc); + } + + @Override + public KMSDatabaseKekObjectVO findByObjectId(byte[] objectId) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("objectId", objectId); + return findOneBy(sc); + } + + @Override + public List listByPurpose(KeyPurpose purpose) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("purpose", purpose); + return listBy(sc); + } + + @Override + public List listByKeyType(String keyType) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("keyType", keyType); + return listBy(sc); + } + + @Override + public List listByObjectClass(String objectClass) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("objectClass", objectClass); + return listBy(sc); + } + + @Override + public boolean existsByLabel(String label) { + return findByLabel(label) != null; + } +} diff --git a/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties index 57d436bcea5..ec7bbd38b04 100644 --- a/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties +++ b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties @@ -16,5 +16,4 @@ # under the License. name=database-kms -parent=kmsProvidersRegistry - +parent=kms diff --git a/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml index be2e666a74d..5ec8d157918 100644 --- a/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml +++ b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml @@ -16,21 +16,18 @@ specific language governing permissions and limitations under the License. --> - - - + - + - diff --git a/plugins/kms/pom.xml b/plugins/kms/pom.xml index afff4024e96..fee2c654565 100644 --- a/plugins/kms/pom.xml +++ b/plugins/kms/pom.xml @@ -17,8 +17,8 @@ specific language governing permissions and limitations under the License. --> - 4.0.0 cloudstack-kms-plugins diff --git a/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java b/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java index 5faa377ce3d..a5e87870eab 100644 --- a/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java +++ b/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java @@ -575,11 +575,25 @@ public class CloudStackPrimaryDataStoreDriverImpl implements PrimaryDataStoreDri */ private boolean anyVolumeRequiresEncryption(DataObject ... objects) { for (DataObject o : objects) { - // this fails code smell for returning true twice, but it is more readable than combining all tests into one statement - if (o instanceof VolumeInfo && ((VolumeInfo) o).getPassphraseId() != null) { - return true; - } else if (o instanceof SnapshotInfo && ((SnapshotInfo) o).getBaseVolume().getPassphraseId() != null) { - return true; + // Check for legacy passphrase-based encryption + if (o instanceof VolumeInfo) { + VolumeInfo vol = (VolumeInfo) o; + if (vol.getPassphraseId() != null) { + return true; + } + // Check for KMS-based encryption + if (vol.getKmsWrappedKeyId() != null || vol.getKmsKeyId() != null) { + return true; + } + } else if (o instanceof SnapshotInfo) { + VolumeInfo baseVol = ((SnapshotInfo) o).getBaseVolume(); + if (baseVol.getPassphraseId() != null) { + return true; + } + // Check for KMS-based encryption + if (baseVol.getKmsWrappedKeyId() != null || baseVol.getKmsKeyId() != null) { + return true; + } } } return false; diff --git a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java index 17961dbd955..dcfacfd897d 100644 --- a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java +++ b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java @@ -963,7 +963,7 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic String userSpecifiedName = getVolumeNameFromCommand(cmd); return commitVolume(cmd.getSnapshotId(), caller, owner, displayVolume, zoneId, diskOfferingId, provisioningType, size, minIops, maxIops, parentVolume, userSpecifiedName, - _uuidMgr.generateUuid(Volume.class, cmd.getCustomId()), details); + _uuidMgr.generateUuid(Volume.class, cmd.getCustomId()), details, cmd.getKmsKeyId()); } @Override @@ -977,7 +977,7 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic } private VolumeVO commitVolume(final Long snapshotId, final Account caller, final Account owner, final Boolean displayVolume, final Long zoneId, final Long diskOfferingId, - final Storage.ProvisioningType provisioningType, final Long size, final Long minIops, final Long maxIops, final VolumeVO parentVolume, final String userSpecifiedName, final String uuid, final Map details) { + final Storage.ProvisioningType provisioningType, final Long size, final Long minIops, final Long maxIops, final VolumeVO parentVolume, final String userSpecifiedName, final String uuid, final Map details, final Long kmsKeyId) { return Transaction.execute(new TransactionCallback() { @Override public VolumeVO doInTransaction(TransactionStatus status) { @@ -1023,6 +1023,12 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic } } + // Store KMS key ID if provided (for volume encryption) + if (volume != null && kmsKeyId != null) { + volume.setKmsKeyId(kmsKeyId); + _volsDao.update(volume.getId(), volume); + } + CallContext.current().setEventDetails("Volume ID: " + volume.getUuid()); CallContext.current().putContextParameter(Volume.class, volume.getId()); // Increment resource count during allocation; if actual creation fails, @@ -2679,7 +2685,7 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic } DiskOfferingVO diskOffering = _diskOfferingDao.findById(volumeToAttach.getDiskOfferingId()); - if (diskOffering.getEncrypt() && rootDiskHyperType != HypervisorType.KVM) { + if (diskOffering.getEncrypt() && !(rootDiskHyperType == HypervisorType.KVM)) { throw new InvalidParameterValueException("Volume's disk offering has encryption enabled, but volume encryption is not supported for hypervisor type " + rootDiskHyperType); } 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 89291f48ad7..4727c34ce75 100644 --- a/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java @@ -21,12 +21,17 @@ import com.cloud.event.ActionEvent; import com.cloud.event.EventTypes; import com.cloud.user.Account; import com.cloud.user.AccountManager; +import com.cloud.utils.EnumUtils; +import com.cloud.utils.Pair; import com.cloud.utils.component.ManagerBase; import com.cloud.utils.component.PluggableService; +import com.cloud.exception.PermissionDeniedException; import com.cloud.utils.exception.CloudRuntimeException; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.ResponseGenerator; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.admin.kms.MigrateVolumesToKMSCmd; +import org.apache.cloudstack.api.command.admin.kms.RotateKMSKeyCmd; import org.apache.cloudstack.api.command.user.kms.CreateKMSKeyCmd; import org.apache.cloudstack.api.command.user.kms.DeleteKMSKeyCmd; import org.apache.cloudstack.api.command.user.kms.ListKMSKeysCmd; @@ -43,22 +48,24 @@ import org.apache.cloudstack.framework.kms.WrappedKey; import org.apache.cloudstack.kms.dao.KMSKekVersionDao; import org.apache.cloudstack.kms.dao.KMSKeyDao; import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; +import org.apache.cloudstack.secret.PassphraseVO; +import org.apache.cloudstack.secret.dao.PassphraseDao; +import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import com.cloud.storage.VolumeVO; +import com.cloud.storage.dao.VolumeDao; import javax.inject.Inject; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; -/** - * Implementation of KMS Manager. - * Provides high-level KMS operations with provider abstraction, zone-scoping, - * retry logic, and audit logging. - */ public class KMSManagerImpl extends ManagerBase implements KMSManager, PluggableService { private static final Logger logger = LogManager.getLogger(KMSManagerImpl.class); private static final Map kmsProviderMap = new HashMap<>(); @@ -73,6 +80,10 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable private AccountManager accountManager; @Inject private ResponseGenerator responseGenerator; + @Inject + private VolumeDao volumeDao; + @Inject + private PassphraseDao passphraseDao; private List kmsProviders; // ==================== Provider Management ==================== @@ -139,12 +150,18 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable public void deleteKek(Long zoneId, String kekId) throws KMSException { validateKmsEnabled(zoneId); - // TODO: Check if any wrapped keys use this KEK - // This requires finding KMSKeyVO by kekLabel first, then checking wrapped keys - // For now, allow deletion (will be fixed in Phase 5) - KMSProvider provider = getKMSProviderForZone(zoneId); + // Check if any wrapped keys use this KEK + KMSKeyVO key = kmsKeyDao.findByKekLabel(kekId, provider.getProviderName()); + if (key != null) { + long wrappedKeyCount = kmsKeyDao.countWrappedKeysByKmsKey(key.getId()); + if (wrappedKeyCount > 0) { + throw KMSException.invalidParameter("Cannot delete KEK: " + wrappedKeyCount + + " wrapped key(s) still reference the corresponding KMS key"); + } + } + try { logger.warn("Deleting KEK {} for zone {}", kekId, zoneId); retryOperation(() -> { @@ -187,7 +204,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable } @Override - @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_ROTATE, eventDescription = "rotating KEK", async = true) public String rotateKek(Long zoneId, KeyPurpose purpose, String oldKekLabel, String newKekLabel, int keyBits) throws KMSException { validateKmsEnabled(zoneId); @@ -220,12 +236,11 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable KMSKekVersionVO newVersion = createKekVersion(kmsKey.getId(), newKekId, keyBits); logger.info("KEK rotation: KMS key {} now has {} versions (active: v{}, previous: v{})", - kmsKey.getUuid(), newVersion.getVersionNumber(), newVersion.getVersionNumber(), + kmsKey, newVersion.getVersionNumber(), newVersion.getVersionNumber(), newVersion.getVersionNumber() - 1); - // TODO: Schedule background job to rewrap all DEKs (Phase 5) + // Schedule background job to rewrap all DEKs // This will gradually rewrap wrapped keys to use the new KEK version - return newKekId; } catch (Exception e) { @@ -241,10 +256,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable public byte[] unwrapVolumeKey(WrappedKey wrappedKey, Long zoneId) throws KMSException { validateKmsEnabled(zoneId); - return unwrapDek(wrappedKey); - } - - private byte[] unwrapDek(WrappedKey wrappedKey) throws KMSException { // Determine provider from wrapped key String providerName = wrappedKey.getProviderName(); KMSProvider provider = getKMSProvider(providerName); @@ -306,66 +317,64 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable KMSKekVersionVO.Status.Active); initialVersion = kmsKekVersionDao.persist(initialVersion); - logger.info("Created KMS key '{}' (UUID: {}) with initial KEK version {} for account {} in zone {}", - name, kmsKey.getUuid(), initialVersion.getVersionNumber(), accountId, zoneId); + logger.info("Created KMS key ({}) with initial KEK version {} for account {} in zone {}", + kmsKey, initialVersion.getVersionNumber(), accountId, zoneId); return kmsKey; } @Override public List listUserKMSKeys(Long accountId, Long domainId, Long zoneId, KeyPurpose purpose, KMSKey.State state) { - // List keys accessible to the account (owned by account or in domain) return kmsKeyDao.listAccessibleKeys(accountId, domainId, zoneId, purpose, state); } - // ==================== Health Check ==================== - @Override public KMSKey getUserKMSKey(String uuid, Long callerAccountId) { KMSKeyVO key = kmsKeyDao.findByUuid(uuid); if (key == null || key.getState() == KMSKey.State.Deleted) { return null; } - // Check permission - if (!hasPermission(callerAccountId, uuid)) { + + if (!hasPermission(callerAccountId, key)) { return null; } return key; } - // ==================== Helper Methods ==================== - @Override - public boolean hasPermission(Long callerAccountId, String keyUuid) { - KMSKeyVO key = kmsKeyDao.findByUuid(keyUuid); + public boolean hasPermission(Long callerAccountId, KMSKey key) { if (key == null || key.getState() == KMSKey.State.Deleted) { return false; } - // Owner always has permission if (key.getAccountId() == callerAccountId) { return true; } - // TODO: Domain admin can access keys in their domain/subdomains - // For now, only owner has permission - return false; - } + Account caller = accountManager.getAccount(callerAccountId); + Account owner = accountManager.getAccount(key.getAccountId()); - @Override - @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_DELETE, eventDescription = "deleting user KMS key", async = false) - public void deleteUserKMSKey(String uuid, Long callerAccountId) throws KMSException { - KMSKeyVO key = kmsKeyDao.findByUuid(uuid); - if (key == null) { - throw KMSException.kekNotFound("KMS key not found: " + uuid); + if (caller == null || owner == null) { + return false; } - // Check permission - if (!hasPermission(callerAccountId, uuid)) { - throw KMSException.invalidParameter("No permission to delete KMS key: " + uuid); + try { + accountManager.checkAccess(caller, null, true, owner); + return true; + } catch (PermissionDeniedException e) { + return false; + } + } + + private void deleteUserKMSKey(KMSKeyVO key, Long callerAccountId) throws KMSException { + if (!hasPermission(callerAccountId, key)) { + throw KMSException.invalidParameter("No permission to delete KMS key: " + key.getUuid()); } // Check if key is in use + + // TODO: Check if there are any volumes linked with the kms key and delete accordingly. + // The below check seems incorrect here. long wrappedKeyCount = kmsKeyDao.countWrappedKeysByKmsKey(key.getId()); if (wrappedKeyCount > 0) { throw KMSException.invalidParameter("Cannot delete KMS key: " + wrappedKeyCount + @@ -374,27 +383,16 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable // Soft delete key.setState(KMSKey.State.Deleted); - key.setRemoved(new java.util.Date()); + key.setRemoved(new Date()); kmsKeyDao.update(key.getId(), key); - // Optionally delete KEK from provider (but keep metadata for audit) - // provider.deleteKek(key.getKekLabel()); - - logger.info("Deleted KMS key '{}' (UUID: {})", key.getName(), uuid); + logger.info("Deleted KMS key {}", key); } - @Override - @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_CREATE, eventDescription = "updating user KMS key", async = false) - public KMSKey updateUserKMSKey(String uuid, Long callerAccountId, + private KMSKey updateUserKMSKey(KMSKeyVO key, Long callerAccountId, String name, String description, KMSKey.State state) throws KMSException { - KMSKeyVO key = kmsKeyDao.findByUuid(uuid); - if (key == null) { - throw KMSException.kekNotFound("KMS key not found: " + uuid); - } - - // Check permission - if (!hasPermission(callerAccountId, uuid)) { - throw KMSException.invalidParameter("No permission to update KMS key: " + uuid); + if (!hasPermission(callerAccountId, key)) { + throw KMSException.invalidParameter("No permission to update KMS key: " + key.getUuid()); } boolean updated = false; @@ -416,7 +414,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable if (updated) { kmsKeyDao.update(key.getId(), key); - logger.info("Updated KMS key '{}' (UUID: {})", key.getName(), uuid); + logger.info("Updated KMS key {}", key); } return key; @@ -458,7 +456,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable } // Fallback: try all available versions for decryption - List versions = getKekVersionsForDecryption(kmsKey.getId()); + List versions = kmsKekVersionDao.getVersionsForDecryption(kmsKey.getId()); for (KMSKekVersionVO version : versions) { try { WrappedKey wrapped = new WrappedKey(version.getKekLabel(), kmsKey.getPurpose(), @@ -478,32 +476,23 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable // ==================== Lifecycle Methods ==================== - /** - * Get all KEK versions that can be used for decryption (Active and Previous) - */ - private List getKekVersionsForDecryption(Long kmsKeyId) { - return kmsKekVersionDao.getVersionsForDecryption(kmsKeyId); - } - @Override @ActionEvent(eventType = EventTypes.EVENT_KMS_KEY_WRAP, eventDescription = "generating volume key with specified KEK", async = false) - public WrappedKey generateVolumeKeyWithKek(String kekUuid, Long callerAccountId) throws KMSException { + public WrappedKey generateVolumeKeyWithKek(KMSKey kmsKey, Long callerAccountId) throws KMSException { // Get and validate KMS key - KMSKey kmsKey = getUserKMSKey(kekUuid, callerAccountId); if (kmsKey == null) { - throw KMSException.kekNotFound("KMS key not found or no permission: " + kekUuid); + throw KMSException.kekNotFound("KMS key not found"); } if (kmsKey.getState() != KMSKey.State.Enabled) { - throw KMSException.invalidParameter("KMS key is not enabled: " + kekUuid); + throw KMSException.invalidParameter("KMS key is not enabled: " + kmsKey); } if (kmsKey.getPurpose() != KeyPurpose.VOLUME_ENCRYPTION) { - throw KMSException.invalidParameter("KMS key purpose is not VOLUME_ENCRYPTION: " + kekUuid); + throw KMSException.invalidParameter("KMS key purpose is not VOLUME_ENCRYPTION: " + kmsKey); } - // Get provider KMSProvider provider = getKMSProviderForZone(kmsKey.getZoneId()); // Get active KEK version @@ -522,7 +511,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable // Return WrappedKey with database UUID so it can be looked up later // Note: Volume creation code should look up by UUID and set volume.kmsWrappedKeyId - WrappedKey persistedWrappedKey = new WrappedKey( + wrappedKey = new WrappedKey( wrappedKeyVO.getUuid(), wrappedKey.getKekId(), wrappedKey.getPurpose(), @@ -532,13 +521,12 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable wrappedKey.getCreated(), wrappedKey.getZoneId() ); - wrappedKey = persistedWrappedKey; } catch (Exception e) { throw handleKmsException(e); } - logger.debug("Generated volume key using KMS key '{}' (UUID: {}) with KEK version {}, wrapped key UUID: {}", - kmsKey.getName(), kekUuid, activeVersion.getVersionNumber(), wrappedKey.getId()); + logger.debug("Generated volume key using KMS key {} with KEK version {}, wrapped key UUID: {}", + kmsKey, activeVersion.getVersionNumber(), wrappedKey.getUuid()); return wrappedKey; } @@ -560,7 +548,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable Account caller = CallContext.current().getCallingAccount(); Account targetAccount = caller; - // If account/domain specified, validate permissions and resolve account if (cmd.getAccountName() != null || cmd.getDomainId() != null) { // Only admins and domain admins can create keys for other accounts if (!accountManager.isAdmin(caller.getId()) && @@ -575,7 +562,6 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable throw KMSException.invalidParameter( "Unable to find account " + cmd.getAccountName() + " in domain " + cmd.getDomainId()); } - // Check access accountManager.checkAccess(caller, null, true, targetAccount); } else { throw KMSException.invalidParameter("Both accountName and domainId must be specified together"); @@ -588,7 +574,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable keyPurpose = KeyPurpose.fromString(cmd.getPurpose()); } catch (IllegalArgumentException e) { throw KMSException.invalidParameter("Invalid purpose: " + cmd.getPurpose() + - ". Valid values: VOLUME_ENCRYPTION, TLS_CERT, CONFIG_SECRET"); + ". Valid values: volume, tls"); } // Validate key bits @@ -617,52 +603,42 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable public ListResponse listKMSKeys(ListKMSKeysCmd cmd) { Account caller = CallContext.current().getCallingAccount(); if (caller == null) { - ListResponse response = new ListResponse<>(); - response.setResponses(new java.util.ArrayList<>(), 0); - return response; + return createEmptyListResponse(); } - // Parse purpose if provided KeyPurpose keyPurpose = null; if (cmd.getPurpose() != null) { try { keyPurpose = KeyPurpose.fromString(cmd.getPurpose()); } catch (IllegalArgumentException e) { - // Invalid purpose - will be ignored + throw KMSException.invalidParameter("Invalid purpose: " + cmd.getPurpose() + ". Valid values: volume, tls"); } } - // Parse state if provided KMSKey.State keyState = null; if (cmd.getState() != null) { - try { - keyState = KMSKey.State.valueOf(cmd.getState()); - } catch (IllegalArgumentException e) { - // Invalid state - will be ignored + keyState = EnumUtils.getEnumIgnoreCase(KMSKey.State.class, cmd.getState()); + if (keyState == null) { + throw KMSException.invalidParameter("Invalid state: " + cmd.getState() + ". Valid values: Enabled, Disabled"); } } - // If specific ID requested if (cmd.getId() != null) { - // Look up key by ID to get UUID KMSKeyVO key = kmsKeyDao.findById(cmd.getId()); - if (key == null) { - // Key not found - return empty list + if (key == null || key.getState() == KMSKey.State.Deleted) { + return createEmptyListResponse(); + } + + if (hasPermission(caller.getId(), key)) { + List responses = new ArrayList<>(); + responses.add(responseGenerator.createKMSKeyResponse(key)); ListResponse listResponse = new ListResponse<>(); - listResponse.setResponses(new java.util.ArrayList<>(), 0); + listResponse.setResponses(responses, responses.size()); return listResponse; } - KMSKey kmsKey = getUserKMSKey(key.getUuid(), caller.getId()); - List responses = new java.util.ArrayList<>(); - if (kmsKey != null && hasPermission(caller.getId(), kmsKey.getUuid())) { - responses.add(responseGenerator.createKMSKeyResponse(kmsKey)); - } - ListResponse listResponse = new ListResponse<>(); - listResponse.setResponses(responses, responses.size()); - return listResponse; + return createEmptyListResponse(); } - // List accessible keys List keys = listUserKMSKeys( caller.getId(), caller.getDomainId(), @@ -671,7 +647,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable keyState ); - List responses = new java.util.ArrayList<>(); + List responses = new ArrayList<>(); for (KMSKey key : keys) { responses.add(responseGenerator.createKMSKeyResponse(key)); } @@ -685,27 +661,23 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable public KMSKeyResponse updateKMSKey(UpdateKMSKeyCmd cmd) throws KMSException { Long callerAccountId = CallContext.current().getCallingAccount().getId(); - // Parse state if provided KMSKey.State keyState = null; if (cmd.getState() != null) { - try { - keyState = KMSKey.State.valueOf(cmd.getState()); - if (keyState == KMSKey.State.Deleted) { - throw KMSException.invalidParameter("Cannot set state to Deleted. Use deleteKMSKey instead."); - } - } catch (IllegalArgumentException e) { - throw KMSException.invalidParameter( - "Invalid state: " + cmd.getState() + ". Valid values: Enabled, Disabled"); + keyState = EnumUtils.getEnumIgnoreCase(KMSKey.State.class, cmd.getState()); + if (keyState == KMSKey.State.Deleted) { + throw KMSException.invalidParameter("Cannot set state to Deleted. Use deleteKMSKey instead."); + } + if (keyState == null) { + throw KMSException.invalidParameter("Invalid state: " + cmd.getState() + ". Valid values: Enabled, Disabled"); } } - // Look up key by ID to get UUID KMSKeyVO key = kmsKeyDao.findById(cmd.getId()); if (key == null) { throw KMSException.kekNotFound("KMS key not found: " + cmd.getId()); } - KMSKey updatedKey = updateUserKMSKey(key.getUuid(), callerAccountId, + KMSKey updatedKey = updateUserKMSKey(key, callerAccountId, cmd.getName(), cmd.getDescription(), keyState); return responseGenerator.createKMSKeyResponse(updatedKey); } @@ -714,15 +686,13 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable public SuccessResponse deleteKMSKey(DeleteKMSKeyCmd cmd) throws KMSException { Long callerAccountId = CallContext.current().getCallingAccount().getId(); - // Look up key by ID to get UUID KMSKeyVO key = kmsKeyDao.findById(cmd.getId()); if (key == null) { throw KMSException.kekNotFound("KMS key not found: " + cmd.getId()); } - deleteUserKMSKey(key.getUuid(), callerAccountId); - SuccessResponse response = new SuccessResponse(); - return response; + deleteUserKMSKey(key, callerAccountId); + return new SuccessResponse(); } // ==================== User KEK Management ==================== @@ -754,6 +724,272 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable return newVersion; } + // ==================== Admin Operations ==================== + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_ROTATE, eventDescription = "rotating KMS key", async = true) + public String rotateKMSKey(RotateKMSKeyCmd cmd) throws KMSException { + Integer keyBits = cmd.getKeyBits(); + + KMSKeyVO kmsKey = kmsKeyDao.findById(cmd.getId()); + if (kmsKey == null) { + throw KMSException.kekNotFound("KMS key not found: " + cmd.getId()); + } + + if (kmsKey.getState() != KMSKey.State.Enabled) { + throw KMSException.invalidParameter("KMS key is not enabled: " + kmsKey); + } + + // Get current active version to determine key bits if not provided + int newKeyBits = keyBits != null ? keyBits : kmsKey.getKeyBits(); + KMSKekVersionVO currentActive = getActiveKekVersion(kmsKey.getId()); + + rotateKek( + kmsKey.getZoneId(), + kmsKey.getPurpose(), + currentActive.getKekLabel(), + null, // auto-generate new label + newKeyBits + ); + + KMSKekVersionVO newVersion = getActiveKekVersion(kmsKey.getId()); + + logger.info("KMS key rotation completed: {} -> new KEK version {} (UUID: {})", + kmsKey, newVersion.getVersionNumber(), newVersion.getUuid()); + + // Perform rewrapping of existing wrapped keys + // This runs within the async job context + rewrapWrappedKeysForKMSKey(kmsKey.getId(), newVersion.getId(), 50); + + return newVersion.getUuid(); + } + + @Override + public int rewrapWrappedKeysForKMSKey(Long kmsKeyId, Long newKekVersionId, int batchSize) throws KMSException { + if (kmsKeyId == null || newKekVersionId == null) { + throw KMSException.invalidParameter("kmsKeyId and newKekVersionId must be specified"); + } + + if (batchSize <= 0) { + batchSize = 50; // Default batch size + } + + // Get KMS key and new version + KMSKeyVO kmsKey = kmsKeyDao.findById(kmsKeyId); + if (kmsKey == null) { + throw KMSException.kekNotFound("KMS key not found: " + kmsKeyId); + } + + KMSKekVersionVO newVersion = kmsKekVersionDao.findById(newKekVersionId); + if (newVersion == null || !newVersion.getKmsKeyId().equals(kmsKeyId)) { + throw KMSException.kekNotFound("KEK version not found or doesn't belong to KMS key: " + newKekVersionId); + } + + KMSProvider provider = getKMSProviderForZone(kmsKey.getZoneId()); + + // Get all wrapped keys that need rewrap + List wrappedKeys = kmsWrappedKeyDao.listWrappedKeysForRewrap(kmsKeyId, newKekVersionId); + int totalKeys = wrappedKeys.size(); + int successCount = 0; + int failureCount = 0; + + logger.info("Starting rewrap operation for {} wrapped keys (KMS key: {}, new version: {})", + totalKeys, kmsKey, newKekVersionId); + + for (int i = 0; i < wrappedKeys.size(); i += batchSize) { + int endIndex = Math.min(i + batchSize, wrappedKeys.size()); + List batch = wrappedKeys.subList(i, endIndex); + + for (KMSWrappedKeyVO wrappedKeyVO : batch) { + byte[] dek = null; + try { + // Unwrap with old version + dek = unwrapKey(wrappedKeyVO.getId()); + + // Wrap the existing DEK with new active version + WrappedKey newWrapped = provider.wrapKey( + dek, + kmsKey.getPurpose(), + newVersion.getKekLabel() + ); + + wrappedKeyVO.setKekVersionId(newKekVersionId); + wrappedKeyVO.setWrappedBlob(newWrapped.getWrappedKeyMaterial()); + kmsWrappedKeyDao.update(wrappedKeyVO.getId(), wrappedKeyVO); + + successCount++; + logger.debug("Rewrapped key {} (batch {}/{})", wrappedKeyVO.getId(), + (i / batchSize) + 1, (totalKeys + batchSize - 1) / batchSize); + } catch (Exception e) { + failureCount++; + logger.warn("Failed to rewrap key {}: {}", wrappedKeyVO.getId(), e.getMessage()); + } finally { + // Zeroize DEK + if (dek != null) { + Arrays.fill(dek, (byte) 0); + } + } + } + + logger.info("Processed batch {}/{}: {} success, {} failures", + (i / batchSize) + 1, (totalKeys + batchSize - 1) / batchSize, successCount, failureCount); + } + + // Archive old versions if no wrapped keys reference them + List oldVersions = kmsKekVersionDao.getVersionsForDecryption(kmsKeyId); + for (KMSKekVersionVO oldVersion : oldVersions) { + if (oldVersion.getStatus() == KMSKekVersionVO.Status.Previous) { + List keysUsingVersion = kmsWrappedKeyDao.listByKekVersionId(oldVersion.getId()); + if (keysUsingVersion.isEmpty()) { + oldVersion.setStatus(KMSKekVersionVO.Status.Archived); + kmsKekVersionDao.update(oldVersion.getId(), oldVersion); + logger.info("Archived KEK version {} (no wrapped keys using it)", oldVersion.getVersionNumber()); + } + } + } + + logger.info("Rewrap operation completed: {} success, {} failures out of {} total", + successCount, failureCount, totalKeys); + + return successCount; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_VOLUME_MIGRATE_TO_KMS, eventDescription = "migrating volumes to KMS", async = true) + public int migrateVolumesToKMS(MigrateVolumesToKMSCmd cmd) throws KMSException { + Long zoneId = cmd.getZoneId(); + String accountName = cmd.getAccountName(); + Long domainId = cmd.getDomainId(); + + if (zoneId == null) { + throw KMSException.invalidParameter("zoneId must be specified"); + } + + validateKmsEnabled(zoneId); + + Long accountId = null; + if (accountName != null) { + accountId = accountManager.finalyzeAccountId(accountName, domainId, null, true); + } + + int pageSize = 100; // Process 100 volumes per page to avoid OutOfMemoryError + + // Get provider + KMSProvider provider = getKMSProviderForZone(zoneId); + + int successCount = 0; + int failureCount = 0; + logger.info("Starting migration of volumes to KMS (zone: {}, account: {}, domain: {})", + zoneId, accountId, domainId); + + Pair, Integer> volumeListPair = volumeDao.listVolumesForKMSMigration(zoneId, accountId, domainId, pageSize); + List volumes = volumeListPair.first(); + int totalCount = volumeListPair.second(); + + while (true) { + + if (CollectionUtils.isEmpty(volumes) || totalCount == 0) { + break; + } + + for (VolumeVO volume : volumes) { + try { + // Load passphrase + PassphraseVO passphrase = passphraseDao.findById(volume.getPassphraseId()); + if (passphrase == null) { + logger.warn("Passphrase not found for volume {}: {}", volume.getId(), volume.getPassphraseId()); + failureCount++; + continue; + } + + // Get passphrase bytes + // Note: PassphraseVO.getPassphrase() returns Base64-encoded bytes + // This is consistent with how hypervisors (KVM/QEMU) expect the key format + // The KMS will store the same format, maintaining compatibility + byte[] passphraseBytes = passphrase.getPassphrase(); + + // Get or create KMS key for account + KMSKeyVO kmsKey = null; + List accountKeys = listUserKMSKeys( + volume.getAccountId(), + volume.getDomainId(), + zoneId, + KeyPurpose.VOLUME_ENCRYPTION, + KMSKey.State.Enabled + ); + + if (!accountKeys.isEmpty()) { + kmsKey = (KMSKeyVO) accountKeys.get(0); // Use first available key + } else { + // Create new KMS key for account + String keyName = "Volume-Encryption-Key-" + volume.getAccountId(); + kmsKey = (KMSKeyVO) createUserKMSKey( + volume.getAccountId(), + volume.getDomainId(), + zoneId, + keyName, + "Auto-created for volume migration", + KeyPurpose.VOLUME_ENCRYPTION, + 256 // Default to 256 bits + ); + logger.info("Created KMS key {} for account {} during migration", kmsKey, volume.getAccountId()); + } + + // Get active KEK version + KMSKekVersionVO activeVersion = getActiveKekVersion(kmsKey.getId()); + + // Wrap existing passphrase bytes as DEK (don't generate new DEK) + WrappedKey wrappedKey = provider.wrapKey( + passphraseBytes, + KeyPurpose.VOLUME_ENCRYPTION, + activeVersion.getKekLabel() + ); + + // Store wrapped key + KMSWrappedKeyVO wrappedKeyVO = new KMSWrappedKeyVO( + kmsKey.getId(), + activeVersion.getId(), + zoneId, + wrappedKey.getWrappedKeyMaterial() + ); + wrappedKeyVO = kmsWrappedKeyDao.persist(wrappedKeyVO); + + // Update volume + volume.setKmsWrappedKeyId(wrappedKeyVO.getId()); + volume.setKmsKeyId(kmsKey.getId()); + volume.setPassphraseId(null); // Clear passphrase reference + volumeDao.update(volume.getId(), volume); + + // Zeroize passphrase bytes + if (passphraseBytes != null) { + Arrays.fill(passphraseBytes, (byte) 0); + } + + successCount++; + logger.debug("Migrated volume's encryption {} to KMS (batch {})", volume, kmsKey); + } catch (Exception e) { + failureCount++; + logger.warn("Failed to migrate volume {}: {}", volume.getId(), e.getMessage()); + // Continue with next volume + } + } + + logger.debug("Processed {} volumes. success: {}, failure: {}", volumes.size(), + successCount, failureCount); + volumeListPair = volumeDao.listVolumesForKMSMigration(zoneId, accountId, domainId, pageSize); + volumes = volumeListPair.first(); + if (totalCount == volumeListPair.second()) { + logger.debug("{} volumes pending for migration because passphrase was not found or migration failed", totalCount); + break; + } + totalCount = volumeListPair.second(); + } + logger.info("Migration operation completed: {} total volumes processed, {} success, {} failures", + successCount + failureCount, successCount, failureCount); + + return successCount; + } + private void validateKmsEnabled(Long zoneId) throws KMSException { if (zoneId == null) { throw KMSException.invalidParameter("Zone ID cannot be null"); @@ -787,7 +1023,7 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable attempt + 1, maxRetries + 1, e.getMessage()); try { - Thread.sleep((long) retryDelay * (attempt + 1)); // Exponential backoff + Thread.sleep((long) retryDelay * (attempt + 1)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new CloudRuntimeException("Interrupted during retry", ie); @@ -818,8 +1054,9 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable } String providerName = KMSProviderPlugin.value(); - if (kmsProviderMap.containsKey(providerName) && kmsProviderMap.get(providerName) != null) { - configuredKmsProvider = kmsProviderMap.get(providerName); + String providerKey = providerName != null ? providerName.toLowerCase() : null; + if (providerKey != null && kmsProviderMap.containsKey(providerKey) && kmsProviderMap.get(providerKey) != null) { + configuredKmsProvider = kmsProviderMap.get(providerKey); return configuredKmsProvider; } @@ -833,9 +1070,22 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable // ==================== API Response Methods ==================== + /** + * Helper method to create an empty list response + */ + private ListResponse createEmptyListResponse() { + ListResponse response = new ListResponse<>(); + response.setResponses(new ArrayList<>(), 0); + return response; + } + private void initializeKmsProviderMap() { - if (kmsProviderMap != null && kmsProviderMap.size() != kmsProviders.size()) { - for (KMSProvider provider : kmsProviders) { + if (kmsProviders == null) { + return; + } + kmsProviderMap.clear(); + for (KMSProvider provider : kmsProviders) { + if (provider != null) { kmsProviderMap.put(provider.getProviderName().toLowerCase(), provider); logger.info("Registered KMS provider: {}", provider.getProviderName()); } @@ -848,8 +1098,9 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable initializeKmsProviderMap(); String configuredProviderName = KMSProviderPlugin.value(); - if (kmsProviderMap.containsKey(configuredProviderName)) { - configuredKmsProvider = kmsProviderMap.get(configuredProviderName); + String providerKey = configuredProviderName != null ? configuredProviderName.toLowerCase() : null; + if (providerKey != null && kmsProviderMap.containsKey(providerKey)) { + configuredKmsProvider = kmsProviderMap.get(providerKey); logger.info("Configured KMS provider: {}", configuredKmsProvider.getProviderName()); } @@ -898,6 +1149,8 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable cmdList.add(CreateKMSKeyCmd.class); cmdList.add(UpdateKMSKeyCmd.class); cmdList.add(DeleteKMSKeyCmd.class); + cmdList.add(RotateKMSKeyCmd.class); + cmdList.add(MigrateVolumesToKMSCmd.class); return cmdList; } @@ -907,4 +1160,3 @@ public class KMSManagerImpl extends ManagerBase implements KMSManager, Pluggable T execute() throws Exception; } } -