diff --git a/api/pom.xml b/api/pom.xml index c80c3559345..4cdb57b6414 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -71,6 +71,11 @@ cloud-framework-direct-download ${project.version} + + org.apache.cloudstack + cloud-framework-kms + ${project.version} + diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index 42395bf8999..72301d4bda0 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -271,6 +271,14 @@ public class EventTypes { public static final String EVENT_CA_CERTIFICATE_REVOKE = "CA.CERTIFICATE.REVOKE"; public static final String EVENT_CA_CERTIFICATE_PROVISION = "CA.CERTIFICATE.PROVISION"; + // KMS (Key Management Service) events + public static final String EVENT_KMS_KEY_WRAP = "KMS.KEY.WRAP"; + public static final String EVENT_KMS_KEY_UNWRAP = "KMS.KEY.UNWRAP"; + public static final String EVENT_KMS_KEK_CREATE = "KMS.KEK.CREATE"; + 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"; + // Account events public static final String EVENT_ACCOUNT_ENABLE = "ACCOUNT.ENABLE"; public static final String EVENT_ACCOUNT_DISABLE = "ACCOUNT.DISABLE"; diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java index 4d33ba859a5..b8315e6435a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java @@ -89,7 +89,8 @@ public enum ApiCommandResourceType { KubernetesSupportedVersion(null), SharedFS(org.apache.cloudstack.storage.sharedfs.SharedFS.class), Extension(org.apache.cloudstack.extension.Extension.class), - ExtensionCustomAction(org.apache.cloudstack.extension.ExtensionCustomAction.class); + ExtensionCustomAction(org.apache.cloudstack.extension.ExtensionCustomAction.class), + KmsKey(org.apache.cloudstack.kms.KMSKey.class); private final Class clazz; diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index 3d827641358..82ab78d84d3 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -867,6 +867,9 @@ public class ApiConstants { public static final String SORT_BY = "sortby"; public static final String CHANGE_CIDR = "changecidr"; public static final String PURPOSE = "purpose"; + public static final String KMS_KEY_ID = "kmskeyid"; + public static final String KEK_LABEL = "keklabel"; + public static final String KEY_BITS = "keybits"; public static final String IS_TAGGED = "istagged"; public static final String INSTANCE_NAME = "instancename"; public static final String CONSIDER_LAST_HOST = "considerlasthost"; diff --git a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java index 338c1e738df..a9c9cc7850f 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java +++ b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java @@ -76,6 +76,8 @@ import org.apache.cloudstack.api.response.HypervisorGuestOsNamesResponse; import org.apache.cloudstack.api.response.IPAddressResponse; import org.apache.cloudstack.api.response.ImageStoreResponse; import org.apache.cloudstack.api.response.InstanceGroupResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.api.response.InternalLoadBalancerElementResponse; import org.apache.cloudstack.api.response.IpForwardingRuleResponse; import org.apache.cloudstack.api.response.IpQuarantineResponse; @@ -591,4 +593,6 @@ public interface ResponseGenerator { ApiKeyPairResponse createKeyPairResponse(ApiKeyPair keyPair); ListResponse createKeypairPermissionsResponse(List permissions); + + KMSKeyResponse createKMSKeyResponse(KMSKey kmsKey); } 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 new file mode 100644 index 00000000000..08964a88373 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java @@ -0,0 +1,166 @@ +/* + * 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.user.kms; + +import com.cloud.exception.ResourceAllocationException; +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.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "createKMSKey", + description = "Creates a new KMS key (Key Encryption Key) for encryption", + responseObject = KMSKeyResponse.class, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { + private static final String s_name = "createkmskeyresponse"; + + @Inject + private KMSManager kmsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.NAME, + required = true, + type = CommandType.STRING, + description = "Name of the KMS key") + private String name; + + @Parameter(name = ApiConstants.DESCRIPTION, + type = CommandType.STRING, + description = "Description of the KMS key") + private String description; + + @Parameter(name = ApiConstants.PURPOSE, + required = true, + type = CommandType.STRING, + description = "Purpose of the key: VOLUME_ENCRYPTION, TLS_CERT, CONFIG_SECRET") + private String purpose; + + @Parameter(name = ApiConstants.ZONE_ID, + required = true, + type = CommandType.UUID, + entityType = ZoneResponse.class, + description = "Zone ID where the key will be valid") + private Long zoneId; + + @Parameter(name = ApiConstants.ACCOUNT, + type = CommandType.STRING, + description = "Account name (for creating keys for child accounts - requires domain admin or admin)") + private String accountName; + + @Parameter(name = ApiConstants.DOMAIN_ID, + type = CommandType.UUID, + entityType = DomainResponse.class, + description = "Domain ID (for creating keys for child accounts - requires domain admin or admin)") + private Long domainId; + + @Parameter(name = ApiConstants.KEY_BITS, + type = CommandType.INTEGER, + description = "Key size in bits: 128, 192, or 256 (default: 256)") + private Integer keyBits; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getPurpose() { + return purpose; + } + + public Long getZoneId() { + return zoneId; + } + + public String getAccountName() { + return accountName; + } + + public Long getDomainId() { + return domainId; + } + + public Integer getKeyBits() { + return keyBits != null ? keyBits : 256; // Default to 256 bits + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ResourceAllocationException { + try { + KMSKeyResponse response = kmsManager.createKMSKey(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + "Failed to create KMS key: " + e.getMessage()); + } + } + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public long getEntityOwnerId() { + Account caller = CallContext.current().getCallingAccount(); + if (accountName != null || domainId != null) { + return _accountService.finalyzeAccountId(accountName, domainId, null, true); + } + return caller.getId(); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + 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 new file mode 100644 index 00000000000..ab0d8c321b1 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java @@ -0,0 +1,113 @@ +/* + * 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.user.kms; + +import com.cloud.event.EventTypes; +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.command.user.UserCmd; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "deleteKMSKey", + description = "Deletes a KMS key (only if not in use)", + responseObject = SuccessResponse.class, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class DeleteKMSKeyCmd extends BaseAsyncCmd implements UserCmd { + private static final String s_name = "deletekmskeyresponse"; + + @Inject + private KMSManager kmsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ID, + required = true, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "The UUID of the KMS key to delete") + private Long id; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() { + try { + SuccessResponse response = kmsManager.deleteKMSKey(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + "Failed to delete KMS key: " + e.getMessage()); + } + } + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_KMS_KEK_DELETE; + } + + @Override + public String getEventDescription() { + return "deleting KMS key: " + getId(); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + 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 new file mode 100644 index 00000000000..e15560f9599 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java @@ -0,0 +1,112 @@ +/* + * 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.user.kms; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListAccountResourcesCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject.ResponseView; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "listKMSKeys", + description = "Lists KMS keys available to the caller", + responseObject = KMSKeyResponse.class, + responseView = ResponseView.Restricted, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class ListKMSKeysCmd extends BaseListAccountResourcesCmd implements UserCmd { + private static final String s_name = "listkmskeysresponse"; + + @Inject + private KMSManager kmsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ID, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "List KMS key by UUID") + private Long id; + + @Parameter(name = ApiConstants.PURPOSE, + type = CommandType.STRING, + description = "Filter by purpose: VOLUME_ENCRYPTION, TLS_CERT, CONFIG_SECRET") + private String purpose; + + @Parameter(name = ApiConstants.ZONE_ID, + type = CommandType.UUID, + entityType = ZoneResponse.class, + description = "Filter by zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.STATE, + type = CommandType.STRING, + description = "Filter by state: Enabled, Disabled") + private String state; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public String getPurpose() { + return purpose; + } + + public Long getZoneId() { + return zoneId; + } + + public String getState() { + return state; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() { + ListResponse listResponse = kmsManager.listKMSKeys(this); + listResponse.setResponseName(getCommandName()); + setResponseObject(listResponse); + } + + @Override + public String getCommandName() { + 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 new file mode 100644 index 00000000000..62146a30ae7 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java @@ -0,0 +1,138 @@ +/* + * 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.user.kms; + +import com.cloud.event.EventTypes; +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.command.user.UserCmd; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "updateKMSKey", + description = "Updates KMS key name, description, or state", + responseObject = KMSKeyResponse.class, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class UpdateKMSKeyCmd extends BaseAsyncCmd implements UserCmd { + private static final String s_name = "updatekmskeyresponse"; + + @Inject + private KMSManager kmsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ID, + required = true, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "The UUID of the KMS key to update") + private Long id; + + @Parameter(name = ApiConstants.NAME, + type = CommandType.STRING, + description = "New name for the key") + private String name; + + @Parameter(name = ApiConstants.DESCRIPTION, + type = CommandType.STRING, + description = "New description for the key") + private String description; + + @Parameter(name = ApiConstants.STATE, + type = CommandType.STRING, + description = "New state: Enabled or Disabled") + private String state; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getState() { + return state; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() { + try { + KMSKeyResponse response = kmsManager.updateKMSKey(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + "Failed to update KMS key: " + e.getMessage()); + } + } + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_KMS_KEK_CREATE; // Reuse create event type for updates + } + + @Override + public String getEventDescription() { + return "updating KMS key: " + getId(); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.KmsKey; + } +} 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 new file mode 100644 index 00000000000..df9967a19c0 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java @@ -0,0 +1,256 @@ +/* + * 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.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.kms.KMSKey; + +import java.util.Date; + +@EntityReference(value = KMSKey.class) +public class KMSKeyResponse extends BaseResponse implements ControlledEntityResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "the UUID of the key") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "the name of the key") + private String name; + + @SerializedName(ApiConstants.DESCRIPTION) + @Param(description = "the description of the key") + private String description; + + @SerializedName(ApiConstants.PURPOSE) + @Param(description = "the purpose of the key (VOLUME_ENCRYPTION, TLS_CERT, CONFIG_SECRET)") + private String purpose; + + @SerializedName(ApiConstants.ACCOUNT) + @Param(description = "the account owning the key") + private String accountName; + + @SerializedName(ApiConstants.ACCOUNT_ID) + @Param(description = "the account ID owning the key") + private String accountId; + + @SerializedName(ApiConstants.DOMAIN_ID) + @Param(description = "the domain ID of the key") + private String domainId; + + @SerializedName(ApiConstants.DOMAIN) + @Param(description = "the domain name of the key") + private String domainName; + + @SerializedName(ApiConstants.DOMAIN_PATH) + @Param(description = "the domain path of the key") + private String domainPath; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "the zone ID where the key is valid") + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + @Param(description = "the zone name where the key is valid") + private String zoneName; + + @SerializedName(ApiConstants.PROVIDER) + @Param(description = "the KMS provider (database, pkcs11, etc.)") + private String provider; + + @SerializedName(ApiConstants.ALGORITHM) + @Param(description = "the encryption algorithm") + private String algorithm; + + @SerializedName(ApiConstants.KEY_BITS) + @Param(description = "the key size in bits") + private Integer keyBits; + + @SerializedName(ApiConstants.STATE) + @Param(description = "the state of the key (Enabled, Disabled, Deleted)") + private String state; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "the creation timestamp") + private Date created; + + // KEK label is admin-only for security + @SerializedName(ApiConstants.KEK_LABEL) + @Param(description = "the provider-specific KEK label (admin only)", authorized = {RoleType.Admin}) + private String kekLabel; + + // Getters and Setters + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getPurpose() { + return purpose; + } + + public void setPurpose(String purpose) { + this.purpose = purpose; + } + + public String getAccountName() { + return accountName; + } + + @Override + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + @Override + public void setProjectId(String projectId) { + // KMS keys are not project-scoped + } + + @Override + public void setProjectName(String projectName) { + // KMS keys are not project-scoped + } + + public String getAccountId() { + return accountId; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public String getDomainId() { + return domainId; + } + + @Override + public void setDomainId(String domainId) { + this.domainId = domainId; + } + + public String getDomainName() { + return domainName; + } + + @Override + public void setDomainName(String domainName) { + this.domainName = domainName; + } + + public String getDomainPath() { + return domainPath; + } + + @Override + public void setDomainPath(String domainPath) { + this.domainPath = domainPath; + } + + public String getZoneId() { + return zoneId; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public String getZoneName() { + return zoneName; + } + + public void setZoneName(String zoneName) { + this.zoneName = zoneName; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getAlgorithm() { + return algorithm; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public Integer getKeyBits() { + return keyBits; + } + + public void setKeyBits(Integer keyBits) { + this.keyBits = keyBits; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public String getKekLabel() { + return kekLabel; + } + + public void setKekLabel(String kekLabel) { + 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 new file mode 100644 index 00000000000..507b5a5058b --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.kms; + +import org.apache.cloudstack.acl.ControlledEntity; +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; +import org.apache.cloudstack.framework.kms.KeyPurpose; + +import java.util.Date; + +/** + * KMS Key (Key Encryption Key) metadata. + * Represents a KEK that can be used to wrap/unwrap Data Encryption Keys (DEKs). + * KEKs are account-scoped and used for envelope encryption. + */ +public interface KMSKey extends Identity, InternalIdentity, ControlledEntity { + + /** + * Get the user-friendly name of the key + */ + String getName(); + + /** + * Get the description of the key + */ + String getDescription(); + + /** + * Get the provider-specific KEK label/ID + * (internal identifier used by the KMS provider) + */ + String getKekLabel(); + + /** + * Get the purpose of this key + */ + KeyPurpose getPurpose(); + + /** + * Get the zone ID where this key is valid + */ + Long getZoneId(); + + /** + * Get the KMS provider name (e.g., "database", "pkcs11") + */ + String getProviderName(); + + /** + * Get the encryption algorithm (e.g., "AES/GCM/NoPadding") + */ + String getAlgorithm(); + + /** + * Get the key size in bits (e.g., 128, 192, 256) + */ + Integer getKeyBits(); + + /** + * Get the current state of the key + */ + State getState(); + + /** + * Get the creation timestamp + */ + Date getCreated(); + + /** + * Get the removal timestamp (null if not removed) + */ + Date getRemoved(); + + /** + * Key state enumeration + */ + enum State { + /** Key is active and can be used for encryption/decryption */ + Enabled, + /** Key is disabled and cannot be used for new operations */ + Disabled, + /** Key is soft-deleted */ + 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 new file mode 100644 index 00000000000..0f9d6ef54dd --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java @@ -0,0 +1,375 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms; + +import com.cloud.utils.component.Manager; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; +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; +import org.apache.cloudstack.api.command.user.kms.UpdateKMSKeyCmd; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +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 java.util.List; + +/** + * Manager interface for Key Management Service operations. + * Provides high-level API for cryptographic key management with zone-scoping, + * provider abstraction, and integration with CloudStack's configuration system. + */ +public interface KMSManager extends Manager, Configurable { + + // ==================== Configuration Keys ==================== + + /** + * Global: which KMS provider plugin to use by default + * Supported values: "database" (default), "pkcs11", or custom provider names + */ + ConfigKey KMSProviderPlugin = new ConfigKey<>( + "Advanced", + String.class, + "kms.provider.plugin", + "database", + "The KMS provider plugin to use for cryptographic operations (database, pkcs11, etc.)", + true, + ConfigKey.Scope.Global + ); + + /** + * Zone-scoped: enable KMS for a specific zone + * When false (default), new volumes use legacy passphrase encryption + * When true, new volumes use KMS envelope encryption + */ + ConfigKey KMSEnabled = new ConfigKey<>( + "Advanced", + Boolean.class, + "kms.enabled", + "false", + "Enable Key Management Service for disk encryption in this zone", + true, + ConfigKey.Scope.Zone + ); + + /** + * Global: DEK size in bits for volume encryption + * Supported: 128, 192, 256 + */ + ConfigKey KMSDekSizeBits = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.dek.size.bits", + "256", + "The size of Data Encryption Keys (DEK) in bits (128, 192, or 256)", + true, + ConfigKey.Scope.Global + ); + + /** + * Global: retry count for transient KMS failures + */ + ConfigKey KMSRetryCount = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.retry.count", + "3", + "Number of retry attempts for transient KMS failures", + true, + ConfigKey.Scope.Global + ); + + /** + * Global: retry delay in milliseconds + */ + ConfigKey KMSRetryDelayMs = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.retry.delay.ms", + "1000", + "Delay in milliseconds between KMS retry attempts (exponential backoff)", + true, + ConfigKey.Scope.Global + ); + + /** + * Global: timeout for KMS operations in seconds + */ + ConfigKey KMSOperationTimeoutSec = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.operation.timeout.sec", + "30", + "Timeout in seconds for KMS cryptographic operations", + true, + ConfigKey.Scope.Global + ); + + // ==================== Provider Management ==================== + + /** + * List all registered KMS providers + * + * @return list of available providers + */ + List listKMSProviders(); + + /** + * Get a specific KMS provider by name + * + * @param name provider name + * @return the provider, or null if not found + */ + KMSProvider getKMSProvider(String name); + + /** + * Get the configured provider for a zone + * + * @param zoneId the zone ID (null for global default) + * @return the configured provider + * @throws KMSException if no provider configured + */ + KMSProvider getKMSProviderForZone(Long zoneId) throws KMSException; + + /** + * Check if KMS is enabled for a zone + * + * @param zoneId the zone ID + * @return true if KMS is enabled + */ + boolean isKmsEnabled(Long zoneId); + + // ==================== KEK Management ==================== + + /** + * Create a new KEK for a zone and purpose + * + * @param zoneId the zone ID + * @param purpose the key purpose + * @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 (WARNING: makes all DEKs wrapped by it unrecoverable) + * + * @param zoneId the zone ID + * @param kekId the KEK identifier + * @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 + * @param purpose the purpose filter (null for all) + * @return list of KEK identifiers + * @throws KMSException if listing fails + */ + List listKeks(Long zoneId, KeyPurpose purpose) throws KMSException; + + /** + * Check if a KEK is available + * + * @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 (create new one and rewrap all DEKs) + * + * @param zoneId the zone ID + * @param purpose the purpose + * @param oldKekLabel the old KEK label (must be specified) + * @param newKekLabel the new KEK label (null for auto-generated) + * @param keyBits the new KEK size + * @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 ==================== + + /** + * Unwrap a DEK from a wrapped key + * SECURITY: Caller must zeroize returned byte array after use! + * + * @param wrappedKey the wrapped key from database + * @param zoneId the zone ID + * @return plaintext DEK (caller must zeroize!) + * @throws KMSException if unwrap fails + */ + byte[] unwrapVolumeKey(WrappedKey wrappedKey, Long zoneId) throws KMSException; + + // ==================== Health & Status ==================== + + /** + * Check KMS provider health for a zone + * + * @param zoneId the zone ID (null for global) + * @return true if healthy + * @throws KMSException if health check fails critically + */ + boolean healthCheck(Long zoneId) throws KMSException; + + // ==================== User KEK Management ==================== + + /** + * Create a new KMS key (KEK) for a user account + * + * @param accountId the account ID + * @param domainId the domain ID + * @param zoneId the zone ID + * @param name user-friendly name + * @param description optional description + * @param purpose key purpose + * @param keyBits key size in bits + * @return the created KMS key + * @throws KMSException if creation fails + */ + KMSKey createUserKMSKey(Long accountId, Long domainId, Long zoneId, + String name, String description, KeyPurpose purpose, + Integer keyBits) throws KMSException; + + /** + * List KMS keys accessible to a user account + * + * @param accountId the account ID + * @param domainId the domain ID + * @param zoneId optional zone filter + * @param purpose optional purpose filter + * @param state optional state filter + * @return list of accessible KMS keys + */ + List listUserKMSKeys(Long accountId, Long domainId, Long zoneId, + KeyPurpose purpose, KMSKey.State state); + + /** + * Get a KMS key by UUID (with permission check) + * + * @param uuid the key UUID + * @param callerAccountId the caller's account ID + * @return the KMS key, or null if not found or no permission + */ + KMSKey getUserKMSKey(String uuid, Long callerAccountId); + + /** + * Check if caller has permission to use a KMS key + * + * @param callerAccountId the caller's account ID + * @param keyUuid the key UUID + * @return true if caller has permission + */ + boolean hasPermission(Long callerAccountId, String keyUuid); + + /** + * 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 + * + * @param wrappedKeyId the wrapped key database ID + * @return plaintext DEK (caller must zeroize!) + * @throws KMSException if unwrap fails + */ + byte[] unwrapKey(Long wrappedKeyId) throws KMSException; + + /** + * Generate and wrap a DEK using a specific KMS key UUID + * + * @param kekUuid the KMS key UUID + * @param callerAccountId the caller's account ID + * @return wrapped key ready for database storage + * @throws KMSException if operation fails + */ + WrappedKey generateVolumeKeyWithKek(String kekUuid, Long callerAccountId) throws KMSException; + + // ==================== API Response Methods ==================== + + /** + * Create a KMS key and return the response object. + * Handles validation, account resolution, and permission checks. + * + * @param cmd the create command with all parameters + * @return KMSKeyResponse + * @throws KMSException if creation fails + */ + KMSKeyResponse createKMSKey(CreateKMSKeyCmd cmd) throws KMSException; + + /** + * List KMS keys and return the response object. + * Handles validation and permission checks. + * + * @param cmd the list command with all parameters + * @return ListResponse with KMSKeyResponse objects + */ + ListResponse listKMSKeys(ListKMSKeysCmd cmd); + + /** + * Update a KMS key and return the response object. + * Handles validation and permission checks. + * + * @param cmd the update command with all parameters + * @return KMSKeyResponse + * @throws KMSException if update fails + */ + KMSKeyResponse updateKMSKey(UpdateKMSKeyCmd cmd) throws KMSException; + + /** + * Delete a KMS key and return the response object. + * Handles validation and permission checks. + * + * @param cmd the delete command with all parameters + * @return SuccessResponse + * @throws KMSException if deletion fails + */ + SuccessResponse deleteKMSKey(DeleteKMSKeyCmd cmd) throws KMSException; +} diff --git a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml index 01c568d7891..15465c22f8f 100644 --- a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml +++ b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml @@ -366,4 +366,7 @@ + + + diff --git a/engine/schema/pom.xml b/engine/schema/pom.xml index 654cd14a25d..664d4909e67 100644 --- a/engine/schema/pom.xml +++ b/engine/schema/pom.xml @@ -48,6 +48,11 @@ cloud-framework-db ${project.version} + + org.apache.cloudstack + cloud-framework-kms + ${project.version} + com.mysql mysql-connector-j 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 653be54a910..126c8144d35 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_wrapped_key_id") + private Long kmsWrappedKeyId; + @Column(name = "encrypt_format") private String encryptFormat; @@ -683,6 +686,10 @@ public class VolumeVO implements Volume { public void setPassphraseId(Long id) { this.passphraseId = id; } + public Long getKmsWrappedKeyId() { return kmsWrappedKeyId; } + + public void setKmsWrappedKeyId(Long id) { this.kmsWrappedKeyId = id; } + public String getEncryptFormat() { return encryptFormat; } public void setEncryptFormat(String encryptFormat) { this.encryptFormat = encryptFormat; } 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 new file mode 100644 index 00000000000..36f9661b0fc --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java @@ -0,0 +1,189 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms; + +import com.cloud.utils.db.GenericDao; + +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 versions. + * Tracks multiple KEK versions per KMS key to support gradual rotation. + * During rotation, a new version is created (status=Active) and old versions + * are marked as Previous (still usable for decryption) or Archived (no longer used). + */ +@Entity +@Table(name = "kms_kek_versions") +public class KMSKekVersionVO { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "uuid", nullable = false, unique = true) + private String uuid; + + @Column(name = "kms_key_id", nullable = false) + private Long kmsKeyId; + + @Column(name = "version_number", nullable = false) + private Integer versionNumber; + + @Column(name = "kek_label", nullable = false) + private String kekLabel; + + @Column(name = "status", nullable = false, length = 32) + @Enumerated(EnumType.STRING) + private Status status; + + @Column(name = GenericDao.CREATED_COLUMN, nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(TemporalType.TIMESTAMP) + private Date removed; + + /** + * Status of a KEK version + */ + public enum Status { + /** + * Active version - used for new encryption operations + */ + Active, + /** + * Previous version - still usable for decryption during rotation + */ + Previous, + /** + * Archived version - no longer used (after re-encryption complete) + */ + Archived + } + + /** + * Default constructor (required by JPA) + */ + public KMSKekVersionVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + this.status = Status.Active; + } + + /** + * Constructor for creating a new KEK version + * + * @param kmsKeyId the KMS key ID this version belongs to + * @param versionNumber the version number (1, 2, 3, ...) + * @param kekLabel the provider-specific KEK label + * @param status the status (typically Active for new versions) + */ + public KMSKekVersionVO(Long kmsKeyId, Integer versionNumber, String kekLabel, Status status) { + this(); + this.kmsKeyId = kmsKeyId; + this.versionNumber = versionNumber; + this.kekLabel = kekLabel; + this.status = status; + } + + // Getters and Setters + + 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 Long getKmsKeyId() { + return kmsKeyId; + } + + public void setKmsKeyId(Long kmsKeyId) { + this.kmsKeyId = kmsKeyId; + } + + public Integer getVersionNumber() { + return versionNumber; + } + + public void setVersionNumber(Integer versionNumber) { + this.versionNumber = versionNumber; + } + + public String getKekLabel() { + return kekLabel; + } + + public void setKekLabel(String kekLabel) { + this.kekLabel = kekLabel; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + @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); + } +} + 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 new file mode 100644 index 00000000000..16aa6f9ebb5 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java @@ -0,0 +1,277 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.kms.KeyPurpose; + +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 KMS Key (Key Encryption Key) metadata. + * Tracks ownership, purpose, and lifecycle of KEKs used in envelope encryption. + */ +@Entity +@Table(name = "kms_keys") +public class KMSKeyVO implements KMSKey { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "uuid", nullable = false, unique = true) + private String uuid; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description", length = 1024) + private String description; + + @Column(name = "kek_label", nullable = false) + private String kekLabel; + + @Column(name = "purpose", nullable = false, length = 32) + @Enumerated(EnumType.STRING) + private KeyPurpose purpose; + + @Column(name = "account_id", nullable = false) + private Long accountId; + + @Column(name = "domain_id", nullable = false) + private Long domainId; + + @Column(name = "zone_id", nullable = false) + private Long zoneId; + + @Column(name = "provider_name", nullable = false, length = 64) + private String providerName; + + @Column(name = "algorithm", nullable = false, length = 64) + private String algorithm; + + @Column(name = "key_bits", nullable = false) + private Integer keyBits; + + @Column(name = "state", nullable = false, length = 32) + @Enumerated(EnumType.STRING) + private State state; + + @Column(name = GenericDao.CREATED_COLUMN, nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + @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) { + this(); + this.name = name; + this.description = description; + this.kekLabel = kekLabel; + this.purpose = purpose; + this.accountId = accountId; + this.domainId = domainId; + this.zoneId = zoneId; + this.providerName = providerName; + this.algorithm = algorithm; + this.keyBits = keyBits; + } + + // Identity interface methods + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + // KMSKey interface methods + + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public String getKekLabel() { + return kekLabel; + } + + @Override + public KeyPurpose getPurpose() { + return purpose; + } + + @Override + public Long getZoneId() { + return zoneId; + } + + @Override + public String getProviderName() { + return providerName; + } + + @Override + public String getAlgorithm() { + return algorithm; + } + + @Override + public Integer getKeyBits() { + return keyBits; + } + + @Override + public State getState() { + return state; + } + + @Override + public Date getCreated() { + return created; + } + + @Override + public Date getRemoved() { + return removed; + } + + // ControlledEntity interface methods + + @Override + public long getAccountId() { + return accountId; + } + + @Override + public long getDomainId() { + return domainId; + } + + @Override + public Class getEntityType() { + return KMSKey.class; + } + + // Setters + + public void setId(Long id) { + this.id = id; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setKekLabel(String kekLabel) { + this.kekLabel = kekLabel; + } + + public void setPurpose(KeyPurpose purpose) { + this.purpose = purpose; + } + + public void setAccountId(Long accountId) { + this.accountId = accountId; + } + + public void setDomainId(Long domainId) { + this.domainId = domainId; + } + + public void setZoneId(Long zoneId) { + this.zoneId = zoneId; + } + + public void setProviderName(String providerName) { + this.providerName = providerName; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public void setKeyBits(Integer keyBits) { + this.keyBits = keyBits; + } + + public void setState(State state) { + this.state = state; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + @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); + } +} + 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 new file mode 100644 index 00000000000..77f99e88070 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java @@ -0,0 +1,207 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms; + +import com.cloud.utils.db.GenericDao; + +import javax.persistence.Column; +import javax.persistence.Entity; +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.Arrays; +import java.util.Date; +import java.util.UUID; + +/** + * Database entity for storing wrapped (encrypted) Data Encryption Keys. + * Each entry represents a DEK that has been encrypted by a Key Encryption Key (KEK). + * KEK metadata is stored in kms_keys table via the kms_key_id foreign key. + */ +@Entity +@Table(name = "kms_wrapped_key") +public class KMSWrappedKeyVO { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "uuid", nullable = false, unique = true) + private String uuid; + + @Column(name = "kms_key_id") + private Long kmsKeyId; + + @Column(name = "kek_version_id") + private Long kekVersionId; + + @Column(name = "zone_id", nullable = false) + private Long zoneId; + + @Column(name = "wrapped_blob", nullable = false, columnDefinition = "VARBINARY(4096)") + private byte[] wrappedBlob; + + @Column(name = GenericDao.CREATED_COLUMN, nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + @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(); + this.kekVersionId = kekVersionId; + this.zoneId = kmsKey.getZoneId(); + // Defensive copy + 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; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public Long getKmsKeyId() { + return kmsKeyId; + } + + public void setKmsKeyId(Long kmsKeyId) { + this.kmsKeyId = kmsKeyId; + } + + public Long getKekVersionId() { + return kekVersionId; + } + + public void setKekVersionId(Long kekVersionId) { + this.kekVersionId = kekVersionId; + } + + public Long getZoneId() { + return zoneId; + } + + public void setZoneId(Long zoneId) { + this.zoneId = zoneId; + } + + 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; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + @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 + + '}'; + } +} + 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 new file mode 100644 index 00000000000..75cae5dbbb6 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDao.java @@ -0,0 +1,60 @@ +// 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.dao; + +import com.cloud.utils.db.GenericDao; +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 + */ + KMSKekVersionVO getActiveVersion(Long kmsKeyId); + + /** + * Get all versions that can be used for decryption (Active and Previous) + */ + List getVersionsForDecryption(Long kmsKeyId); + + /** + * List all versions for a KMS key + */ + List listByKmsKeyId(Long kmsKeyId); + + /** + * Find a specific version by KMS key ID and version number + */ + KMSKekVersionVO findByKmsKeyIdAndVersion(Long kmsKeyId, Integer versionNumber); + + /** + * Find a KEK version by KEK label + */ + 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 new file mode 100644 index 00000000000..d052d069a39 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java @@ -0,0 +1,129 @@ +// 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.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.kms.KMSKekVersionVO; +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; + + 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); + } + + @Override + public KMSKekVersionVO getActiveVersion(Long kmsKeyId) { + SearchCriteria sc = activeVersionSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + sc.setParameters("status", KMSKekVersionVO.Status.Active); + return findOneBy(sc); + } + + @Override + public List getVersionsForDecryption(Long kmsKeyId) { + SearchCriteria sc = decryptionVersionsSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + sc.setParameters("status", KMSKekVersionVO.Status.Active, KMSKekVersionVO.Status.Previous); + return listBy(sc); + } + + @Override + public List listByKmsKeyId(Long kmsKeyId) { + SearchCriteria sc = kmsKeyIdSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + return listBy(sc); + } + + @Override + public KMSKekVersionVO findByKmsKeyIdAndVersion(Long kmsKeyId, Integer versionNumber) { + SearchCriteria sc = versionNumberSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + sc.setParameters("versionNumber", versionNumber); + return findOneBy(sc); + } + + @Override + public KMSKekVersionVO findByKekLabel(String kekLabel) { + SearchCriteria sc = kekLabelSearch.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 new file mode 100644 index 00000000000..b5f4c619aa3 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java @@ -0,0 +1,72 @@ +// 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.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.KMSKey; +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 + */ + KMSKeyVO findByKekLabel(String kekLabel, String providerName); + + /** + * List KMS keys owned by an account + */ + 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 + */ + List listByZone(Long zoneId, KeyPurpose purpose, KMSKey.State state); + + /** + * List KMS keys accessible to an account (owns or in parent domain) + */ + List listAccessibleKeys(Long accountId, Long domainId, Long zoneId, KeyPurpose purpose, KMSKey.State state); + + /** + * Count how many wrapped keys reference this KEK + */ + long countWrappedKeysByKmsKey(Long kmsKeyId); + + /** + * Count KEKs by label (to check for duplicates) + */ + 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 new file mode 100644 index 00000000000..9e6a58dba55 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java @@ -0,0 +1,189 @@ +// 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.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.KMSKey; +import org.apache.cloudstack.kms.KMSKeyVO; +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; + + @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); + } + + @Override + public KMSKeyVO findByKekLabel(String kekLabel, String providerName) { + SearchCriteria sc = kekLabelSearch.create(); + sc.setParameters("kekLabel", kekLabel); + sc.setParameters("providerName", providerName); + return findOneBy(sc); + } + + @Override + public List listByAccount(Long accountId, KeyPurpose purpose, KMSKey.State state) { + SearchCriteria sc = accountSearch.create(); + sc.setParameters("accountId", accountId); + if (purpose != null) { + sc.setParameters("purpose", purpose); + } + if (state != null) { + sc.setParameters("state", state); + } + 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(); + sc.setParameters("zoneId", zoneId); + if (purpose != null) { + sc.setParameters("purpose", purpose); + } + if (state != null) { + sc.setParameters("state", state); + } + return listBy(sc); + } + + @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 + sc.setParameters("accountId", accountId); + if (zoneId != null) { + sc.setParameters("zoneId", zoneId); + } + if (purpose != null) { + sc.setParameters("purpose", purpose); + } + if (state != null) { + sc.setParameters("state", state); + } + return listBy(sc); + } + + @Override + public long countWrappedKeysByKmsKey(Long kmsKeyId) { + if (kmsKeyId == null) { + return 0; + } + // Delegate to KMSWrappedKeyDao + return kmsWrappedKeyDao.countByKmsKeyId(kmsKeyId); + } + + @Override + public long countByKekLabel(String kekLabel, String providerName) { + SearchCriteria sc = kekLabelSearch.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 new file mode 100644 index 00000000000..09210bcc17c --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java @@ -0,0 +1,73 @@ +// 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.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.kms.KMSWrappedKeyVO; + +import java.util.List; + +/** + * Data Access Object for KMS Wrapped Keys. + * This DAO is purpose-agnostic and can be used for any key purpose + * (volumes, TLS certs, config secrets, etc.) + */ +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) + * + * @param kmsKeyId the KMS key ID (FK to kms_keys) + * @return list of wrapped keys + */ + List listByKmsKeyId(Long kmsKeyId); + + /** + * List all wrapped keys in a zone + * + * @param zoneId the zone ID + * @return list of wrapped keys + */ + List listByZone(Long zoneId); + + /** + * Count wrapped keys using a specific KMS key + * + * @param kmsKeyId the KMS key ID (FK to kms_keys) + * @return count of keys + */ + long countByKmsKeyId(Long kmsKeyId); + + /** + * List all wrapped keys using a specific KEK version + * + * @param kekVersionId the KEK version ID (FK to kms_kek_versions) + * @return list of wrapped keys + */ + List listByKekVersionId(Long kekVersionId); +} + 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 new file mode 100644 index 00000000000..ccd44ac4dac --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java @@ -0,0 +1,103 @@ +// 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.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.kms.KMSWrappedKeyVO; +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; + + 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(); + + // 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); + } + + @Override + public List listByKmsKeyId(Long kmsKeyId) { + SearchCriteria sc = kmsKeyIdSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + return listBy(sc); + } + + @Override + public List listByZone(Long zoneId) { + SearchCriteria sc = zoneSearch.create(); + sc.setParameters("zoneId", zoneId); + return listBy(sc); + } + + @Override + public long countByKmsKeyId(Long kmsKeyId) { + SearchCriteria sc = kmsKeyIdSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + Integer count = getCount(sc); + return count != null ? count.longValue() : 0L; + } + + @Override + public List listByKekVersionId(Long kekVersionId) { + SearchCriteria sc = kekVersionIdSearch.create(); + sc.setParameters("kekVersionId", kekVersionId); + return listBy(sc); + } +} + diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml index edc14d9fa0c..a5e4b07931a 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml @@ -312,4 +312,7 @@ + + + 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 d69b524b85d..ff5651ba38f 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 @@ -63,6 +63,7 @@ CREATE TABLE IF NOT EXISTS `cloud`.`webhook_filter` ( CONSTRAINT `fk_webhook_filter__webhook_id` FOREIGN KEY(`webhook_id`) REFERENCES `webhook`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- "api_keypair" table for API and secret keys CREATE TABLE IF NOT EXISTS `cloud`.`api_keypair` ( `id` bigint(20) unsigned NOT NULL auto_increment, @@ -114,3 +115,77 @@ CALL `cloud`.`IDEMPOTENT_UPDATE_API_PERMISSION`('Resource Admin', 'deleteUserKey -- Add conserve mode for VPC offerings CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tinyint(1) unsigned NULL DEFAULT 0 COMMENT ''True if the VPC offering is IP conserve mode enabled, allowing public IP services to be used across multiple VPC tiers'' '); + +-- KMS Keys (Key Encryption Key Metadata) +-- Account-scoped KEKs for envelope encryption +CREATE TABLE IF NOT EXISTS `cloud`.`kms_keys` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Unique ID', + `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID - user-facing identifier', + `name` VARCHAR(255) NOT NULL COMMENT 'User-friendly name', + `description` VARCHAR(1024) COMMENT 'User description', + `kek_label` VARCHAR(255) NOT NULL COMMENT 'Provider-specific KEK label/ID', + `purpose` VARCHAR(32) NOT NULL COMMENT 'Key purpose (VOLUME_ENCRYPTION, TLS_CERT, CONFIG_SECRET)', + `account_id` BIGINT UNSIGNED NOT NULL COMMENT 'Owning account', + `domain_id` BIGINT UNSIGNED NOT NULL COMMENT 'Owning domain', + `zone_id` BIGINT UNSIGNED NOT NULL COMMENT 'Zone where key is valid', + `provider_name` VARCHAR(64) NOT NULL COMMENT 'KMS provider (database, pkcs11, etc.)', + `algorithm` VARCHAR(64) NOT NULL DEFAULT 'AES/GCM/NoPadding' COMMENT 'Encryption algorithm', + `key_bits` INT NOT NULL DEFAULT 256 COMMENT 'Key size in bits', + `state` VARCHAR(32) NOT NULL DEFAULT 'Enabled' COMMENT 'Enabled, Disabled, or Deleted', + `created` DATETIME NOT NULL COMMENT 'Creation timestamp', + `removed` DATETIME COMMENT 'Removal timestamp for soft delete', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_uuid` (`uuid`), + INDEX `idx_account_purpose` (`account_id`, `purpose`, `state`), + INDEX `idx_domain_purpose` (`domain_id`, `purpose`, `state`), + INDEX `idx_zone_state` (`zone_id`, `state`), + INDEX `idx_kek_label_provider` (`kek_label`, `provider_name`), + CONSTRAINT `fk_kms_keys__account_id` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_kms_keys__domain_id` FOREIGN KEY (`domain_id`) REFERENCES `domain`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_kms_keys__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KMS Key (KEK) metadata - account-scoped keys for envelope encryption'; + +-- KMS KEK Versions (multiple KEKs per KMS key for gradual rotation) +-- Supports multiple KEK versions per logical KMS key during rotation +CREATE TABLE IF NOT EXISTS `cloud`.`kms_kek_versions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Unique ID', + `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID', + `kms_key_id` BIGINT UNSIGNED NOT NULL COMMENT 'Reference to kms_keys table', + `version_number` INT NOT NULL COMMENT 'Version number (1, 2, 3, ...)', + `kek_label` VARCHAR(255) NOT NULL COMMENT 'Provider-specific KEK label/ID for this version', + `status` VARCHAR(32) NOT NULL DEFAULT 'Active' COMMENT 'Active, Previous, Archived', + `created` DATETIME NOT NULL COMMENT 'Creation timestamp', + `removed` DATETIME COMMENT 'Removal timestamp for soft delete', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_uuid` (`uuid`), + UNIQUE KEY `uk_kms_key_version` (`kms_key_id`, `version_number`, `removed`), + INDEX `idx_kms_key_status` (`kms_key_id`, `status`, `removed`), + INDEX `idx_kek_label` (`kek_label`), + CONSTRAINT `fk_kms_kek_versions__kms_key_id` FOREIGN KEY (`kms_key_id`) REFERENCES `kms_keys`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KEK versions for a KMS key - supports gradual rotation'; + +-- KMS Wrapped Keys (Data Encryption Keys) +-- Generic table for wrapped DEKs - references kms_keys for metadata and kek_versions for specific KEK version +CREATE TABLE IF NOT EXISTS `cloud`.`kms_wrapped_key` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Unique ID', + `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID', + `kms_key_id` BIGINT UNSIGNED COMMENT 'Reference to kms_keys table', + `kek_version_id` BIGINT UNSIGNED COMMENT 'Reference to kms_kek_versions table', + `zone_id` BIGINT UNSIGNED NOT NULL COMMENT 'Zone ID for zone-scoped keys', + `wrapped_blob` VARBINARY(4096) NOT NULL COMMENT 'Encrypted DEK material', + `created` DATETIME NOT NULL COMMENT 'Creation timestamp', + `removed` DATETIME COMMENT 'Removal timestamp for soft delete', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_uuid` (`uuid`), + INDEX `idx_kms_key_id` (`kms_key_id`, `removed`), + INDEX `idx_kek_version_id` (`kek_version_id`, `removed`), + INDEX `idx_zone_id` (`zone_id`, `removed`), + CONSTRAINT `fk_kms_wrapped_key__kms_key_id` FOREIGN KEY (`kms_key_id`) REFERENCES `kms_keys`(`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_kms_wrapped_key__kek_version_id` FOREIGN KEY (`kek_version_id`) REFERENCES `kms_kek_versions`(`id`) ON DELETE RESTRICT, + 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 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`)'); + diff --git a/framework/kms/pom.xml b/framework/kms/pom.xml new file mode 100644 index 00000000000..adb7d9bd449 --- /dev/null +++ b/framework/kms/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + cloud-framework-kms + Apache CloudStack Framework - Key Management Service + Core KMS framework with provider-agnostic interfaces + + + org.apache.cloudstack + cloudstack-framework + 4.23.0.0-SNAPSHOT + ../pom.xml + + + + + org.apache.cloudstack + cloud-utils + ${project.version} + + + org.apache.cloudstack + cloud-framework-config + ${project.version} + + + 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 new file mode 100644 index 00000000000..58b8d251a57 --- /dev/null +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java @@ -0,0 +1,179 @@ +// 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 com.cloud.utils.exception.CloudRuntimeException; + +/** + * Exception class for KMS-related errors with structured error types + * to enable proper retry logic and error handling. + */ +public class KMSException extends CloudRuntimeException { + + /** + * Error types for KMS operations to enable intelligent retry logic + */ + public enum ErrorType { + /** + * Provider not initialized or unavailable + */ + PROVIDER_NOT_INITIALIZED(false), + + /** + * KEK not found in backend + */ + KEK_NOT_FOUND(false), + + /** + * KEK with given label already exists + */ + KEY_ALREADY_EXISTS(false), + + /** + * Invalid parameters provided + */ + INVALID_PARAMETER(false), + + /** + * Wrap/unwrap operation failed + */ + WRAP_UNWRAP_FAILED(true), + + /** + * KEK operation (create/delete) failed + */ + KEK_OPERATION_FAILED(true), + + /** + * Health check failed + */ + HEALTH_CHECK_FAILED(true), + + /** + * Transient network or communication error + */ + TRANSIENT_ERROR(true), + + /** + * Unknown error + */ + UNKNOWN(false); + + private final boolean retryable; + + ErrorType(boolean retryable) { + this.retryable = retryable; + } + + public boolean isRetryable() { + return retryable; + } + } + + private final ErrorType errorType; + + public KMSException(String message) { + super(message); + this.errorType = ErrorType.UNKNOWN; + } + + public KMSException(String message, Throwable cause) { + super(message, cause); + this.errorType = ErrorType.UNKNOWN; + } + + public KMSException(ErrorType errorType, String message) { + super(message); + this.errorType = errorType; + } + + public KMSException(ErrorType errorType, String message, Throwable cause) { + super(message, cause); + this.errorType = errorType; + } + + public static KMSException providerNotInitialized(String details) { + return new KMSException(ErrorType.PROVIDER_NOT_INITIALIZED, + "KMS provider not initialized: " + details); + } + + public static KMSException kekNotFound(String kekId) { + return new KMSException(ErrorType.KEK_NOT_FOUND, + "KEK not found: " + kekId); + } + + // Static factory methods for common error types + + public static KMSException keyAlreadyExists(String details) { + return new KMSException(ErrorType.KEY_ALREADY_EXISTS, + "Key already exists: " + details); + } + + public static KMSException invalidParameter(String details) { + return new KMSException(ErrorType.INVALID_PARAMETER, + "Invalid parameter: " + details); + } + + public static KMSException wrapUnwrapFailed(String details, Throwable cause) { + return new KMSException(ErrorType.WRAP_UNWRAP_FAILED, + "Wrap/unwrap operation failed: " + details, cause); + } + + public static KMSException wrapUnwrapFailed(String details) { + return new KMSException(ErrorType.WRAP_UNWRAP_FAILED, + "Wrap/unwrap operation failed: " + details); + } + + public static KMSException kekOperationFailed(String details, Throwable cause) { + return new KMSException(ErrorType.KEK_OPERATION_FAILED, + "KEK operation failed: " + details, cause); + } + + public static KMSException kekOperationFailed(String details) { + return new KMSException(ErrorType.KEK_OPERATION_FAILED, + "KEK operation failed: " + details); + } + + public static KMSException healthCheckFailed(String details, Throwable cause) { + return new KMSException(ErrorType.HEALTH_CHECK_FAILED, + "Health check failed: " + details, cause); + } + + public static KMSException transientError(String details, Throwable cause) { + return new KMSException(ErrorType.TRANSIENT_ERROR, + "Transient error: " + details, cause); + } + + public ErrorType getErrorType() { + return errorType; + } + + @Override + public String toString() { + return "KMSException{" + + "errorType=" + errorType + + ", retryable=" + isRetryable() + + ", message='" + getMessage() + '\'' + + '}'; + } + + public boolean isRetryable() { + 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 new file mode 100644 index 00000000000..cfee06f6278 --- /dev/null +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java @@ -0,0 +1,144 @@ +// 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 org.apache.cloudstack.framework.config.Configurable; + +import java.util.List; + +/** + * Abstract provider contract for Key Management Service operations. + *

+ * Implementations provide the cryptographic backend (HSM via PKCS#11, database, cloud KMS, etc.) + * for secure key wrapping/unwrapping using envelope encryption. + *

+ * Design principles: + * - KEKs (Key Encryption Keys) never leave the secure backend + * - DEKs (Data Encryption Keys) are wrapped by KEKs for storage + * - Plaintext DEKs exist only transiently in memory during wrap/unwrap + * - All operations are purpose-scoped to prevent key reuse + *

+ * Thread-safety: Implementations must be thread-safe for concurrent operations. + */ +public interface KMSProvider extends Configurable { + + /** + * Get the unique name of this provider + * + * @return provider name (e.g., "database", "pkcs11") + */ + String getProviderName(); + + // ==================== KEK Management ==================== + + /** + * Create a new Key Encryption Key (KEK) in the secure backend + * + * @param purpose the purpose/scope for this KEK + * @param label human-readable label for the KEK (must be unique within purpose) + * @param keyBits key size in bits (typically 128, 192, or 256) + * @return the KEK identifier (label or handle) for later reference + * @throws KMSException if KEK creation fails + */ + String createKek(KeyPurpose purpose, String label, int keyBits) throws KMSException; + + /** + * Delete a KEK from the secure backend. + * WARNING: This will make all DEKs wrapped by this KEK unrecoverable. + * + * @param kekId the KEK identifier to delete + * @throws KMSException if deletion fails or KEK not found + */ + void deleteKek(String kekId) throws KMSException; + + /** + * List all KEK identifiers for a given purpose + * + * @param purpose the key purpose to filter by (null = all purposes) + * @return list of KEK identifiers + * @throws KMSException if listing fails + */ + List listKeks(KeyPurpose purpose) throws KMSException; + + /** + * Check if a KEK exists and is accessible + * + * @param kekId the KEK identifier to check + * @return true if KEK is available + * @throws KMSException if check fails + */ + boolean isKekAvailable(String kekId) throws KMSException; + + // ==================== DEK Operations ==================== + + /** + * Wrap (encrypt) a plaintext Data Encryption Key with a KEK + * + * @param plainDek the plaintext DEK to wrap (caller must zeroize after call) + * @param purpose the intended purpose of this DEK + * @param kekLabel the label of the KEK to use for wrapping + * @return WrappedKey containing the encrypted DEK and metadata + * @throws KMSException if wrapping fails or KEK not found + */ + WrappedKey wrapKey(byte[] plainDek, KeyPurpose purpose, String kekLabel) throws KMSException; + + /** + * Unwrap (decrypt) a wrapped DEK to obtain the plaintext key + *

+ * SECURITY: Caller MUST zeroize the returned byte array after use + * + * @param wrappedKey the wrapped key to decrypt + * @return plaintext DEK (caller must zeroize!) + * @throws KMSException if unwrapping fails or KEK not found + */ + byte[] unwrapKey(WrappedKey wrappedKey) throws KMSException; + + /** + * Generate a new random DEK and immediately wrap it with a KEK + * (convenience method combining generation + wrapping) + * + * @param purpose the intended purpose of the new DEK + * @param kekLabel the label of the KEK to use for wrapping + * @param keyBits DEK size in bits (typically 128, 192, or 256) + * @return WrappedKey containing the newly generated and wrapped DEK + * @throws KMSException if generation or wrapping fails + */ + WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits) throws KMSException; + + /** + * Rewrap a DEK with a different KEK (used during key rotation). + * This unwraps with the old KEK and wraps with the new KEK without exposing the plaintext DEK. + * + * @param oldWrappedKey the currently wrapped key + * @param newKekLabel the label of the new KEK to wrap with + * @return new WrappedKey encrypted with the new KEK + * @throws KMSException if rewrapping fails + */ + WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel) throws KMSException; + + // ==================== Health & Status ==================== + + /** + * Perform health check on the provider backend + * + * @return true if provider is healthy and operational + * @throws KMSException if health check fails with critical error + */ + 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 new file mode 100644 index 00000000000..d9dc14ea8ca --- /dev/null +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSService.java @@ -0,0 +1,166 @@ +// 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 new file mode 100644 index 00000000000..7cbd544f4c7 --- /dev/null +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java @@ -0,0 +1,82 @@ +// 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; + +/** + * Defines the purpose/usage scope for cryptographic keys in the KMS system. + * This enables proper key segregation and prevents key reuse across different contexts. + */ +public enum KeyPurpose { + /** + * Keys used for encrypting VM disk volumes (LUKS, encrypted storage) + */ + VOLUME_ENCRYPTION("volume", "Volume disk encryption keys"), + + /** + * 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"); + + private final String name; + private final String description; + + KeyPurpose(String name, String description) { + this.name = name; + this.description = description; + } + + /** + * Convert string name to KeyPurpose enum + * + * @param name the string representation of the purpose + * @return matching KeyPurpose + * @throws IllegalArgumentException if no matching purpose found + */ + public static KeyPurpose fromString(String name) { + for (KeyPurpose purpose : KeyPurpose.values()) { + if (purpose.getName().equalsIgnoreCase(name)) { + return purpose; + } + } + throw new IllegalArgumentException("Unknown KeyPurpose: " + name); + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + /** + * Generate a KEK label with purpose prefix + * + * @param customLabel optional custom label suffix + * @return formatted KEK label (e.g., "volume-kek-v1") + */ + public String generateKekLabel(String customLabel) { + 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 new file mode 100644 index 00000000000..fccf45119e7 --- /dev/null +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java @@ -0,0 +1,165 @@ +// 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.Arrays; +import java.util.Date; +import java.util.Objects; + +/** + * Immutable Data Transfer Object representing an encrypted (wrapped) Data Encryption Key. + * The wrapped key material contains the DEK encrypted by a Key Encryption Key (KEK) + * stored in a secure backend (HSM, database, etc.). + *

+ * This follows the envelope encryption pattern: + * - DEK: encrypts actual data (e.g., disk volume) + * - KEK: encrypts the DEK (never leaves secure storage) + * - Wrapped Key: DEK encrypted by KEK, safe to store in database + */ +public class WrappedKey { + private final String id; + private final String kekId; + private final KeyPurpose purpose; + private final String algorithm; + private final byte[] wrappedKeyMaterial; + private final String providerName; + private final Date created; + private final Long zoneId; + + /** + * Create a new WrappedKey instance + * + * @param kekId ID/label of the KEK used to wrap this key + * @param purpose the intended use of this key + * @param algorithm encryption algorithm (e.g., "AES/GCM/NoPadding") + * @param wrappedKeyMaterial the encrypted DEK blob + * @param providerName name of the KMS provider that created this key + * @param created timestamp when key was wrapped + * @param zoneId optional zone ID for zone-scoped keys + */ + 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; + } + + /** + * Constructor for database-loaded keys with ID + */ + public WrappedKey(String id, String kekId, KeyPurpose purpose, String algorithm, + byte[] wrappedKeyMaterial, String providerName, + Date created, Long zoneId) { + this.id = id; + 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; + + 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; + } + + public String getId() { + return id; + } + + public String getKekId() { + return kekId; + } + + public KeyPurpose getPurpose() { + return purpose; + } + + public String getAlgorithm() { + return algorithm; + } + + /** + * Get wrapped key material. Returns a defensive copy to prevent modification. + * Caller is responsible for zeroizing the returned array after use. + */ + public byte[] getWrappedKeyMaterial() { + return Arrays.copyOf(wrappedKeyMaterial, wrappedKeyMaterial.length); + } + + public String getProviderName() { + return providerName; + } + + public Date getCreated() { + return created != null ? new Date(created.getTime()) : null; + } + + public Long getZoneId() { + 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 + '\'' + + ", kekId='" + kekId + '\'' + + ", purpose=" + purpose + + ", algorithm='" + algorithm + '\'' + + ", providerName='" + providerName + '\'' + + ", materialLength=" + (wrappedKeyMaterial != null ? wrappedKeyMaterial.length : 0) + + ", created=" + created + + ", zoneId=" + zoneId + + '}'; + } +} + diff --git a/framework/pom.xml b/framework/pom.xml index 337e5b0268b..95d0bd0694c 100644 --- a/framework/pom.xml +++ b/framework/pom.xml @@ -54,6 +54,7 @@ extensions ipc jobs + kms managed-context quota rest diff --git a/plugins/kms/database/pom.xml b/plugins/kms/database/pom.xml new file mode 100644 index 00000000000..1a2c9271d02 --- /dev/null +++ b/plugins/kms/database/pom.xml @@ -0,0 +1,73 @@ + + + + 4.0.0 + cloud-plugin-kms-database + Apache CloudStack Plugin - KMS Database Provider + Database-backed KMS provider for encrypted key storage + + + org.apache.cloudstack + cloudstack-kms-plugins + 4.23.0.0-SNAPSHOT + ../pom.xml + + + + + org.apache.cloudstack + cloud-framework-kms + ${project.version} + + + org.apache.cloudstack + cloud-framework-config + ${project.version} + + + org.apache.cloudstack + cloud-utils + ${project.version} + + + com.google.crypto.tink + tink + ${cs.tink.version} + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + + + + + + + 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 new file mode 100644 index 00000000000..aab86660657 --- /dev/null +++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java @@ -0,0 +1,390 @@ +// 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; + +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.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.inject.Inject; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Database-backed KMS provider that stores master KEKs encrypted in the configuration 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. + */ +public class DatabaseKMSProvider implements KMSProvider { + // Configuration keys + public static final ConfigKey CacheEnabled = new ConfigKey<>( + "Advanced", + Boolean.class, + "kms.database.cache.enabled", + "true", + "Enable in-memory caching of KEKs for better performance", + true, + ConfigKey.Scope.Global + ); + 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"; + // 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; + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public String createKek(KeyPurpose purpose, String label, int keyBits) throws KMSException { + if (keyBits != 128 && keyBits != 192 && keyBits != 256) { + throw KMSException.invalidParameter("Key size must be 128, 192, or 256 bits"); + } + + if (StringUtils.isEmpty(label)) { + label = generateKekLabel(purpose); + } + + String configKey = buildConfigKey(label); + + // Check if KEK already exists + ConfigurationVO existing = configDao.findByName(configKey); + if (existing != null) { + throw KMSException.keyAlreadyExists("KEK with label " + label + " already exists"); + } + + try { + // Generate random KEK + 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); + + // Cache the KEK + if (CacheEnabled.value()) { + kekCache.put(label, kekBytes); + } + + logger.info("Created KEK with label {} for purpose {}", label, purpose); + return label; + + } catch (Exception e) { + throw KMSException.kekOperationFailed("Failed to create KEK: " + e.getMessage(), e); + } + } + + @Override + public String getConfigComponentName() { + return DatabaseKMSProvider.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[]{ + CacheEnabled + }; + } + + @Override + public void deleteKek(String kekId) throws KMSException { + String configKey = buildConfigKey(kekId); + + ConfigurationVO config = configDao.findByName(configKey); + if (config == null) { + throw KMSException.kekNotFound("KEK with label " + kekId + " not found"); + } + + try { + // Remove from configuration (name is the primary key) + configDao.remove(config.getName()); + + // Remove from cache + byte[] cachedKek = kekCache.remove(kekId); + if (cachedKek != null) { + Arrays.fill(cachedKek, (byte) 0); // Zeroize + } + + 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); + } + } + + @Override + public List listKeks(KeyPurpose purpose) throws KMSException { + 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); + + // Return keys from cache + for (String label : kekCache.keySet()) { + if (purpose == null || label.startsWith(purpose.getName())) { + keks.add(label); + } + } + + return keks; + } catch (Exception e) { + throw KMSException.kekOperationFailed("Failed to list KEKs: " + e.getMessage(), e); + } + } + + @Override + public boolean isKekAvailable(String kekId) throws KMSException { + try { + String configKey = buildConfigKey(kekId); + ConfigurationVO config = configDao.findByName(configKey); + return config != null && config.getValue() != null; + } catch (Exception e) { + logger.warn("Error checking KEK availability: {}", e.getMessage()); + return false; + } + } + + @Override + public WrappedKey wrapKey(byte[] plainKey, KeyPurpose purpose, String kekLabel) throws KMSException { + if (plainKey == null || plainKey.length == 0) { + throw KMSException.invalidParameter("Plain key cannot be null or empty"); + } + + byte[] kekBytes = loadKek(kekLabel); + + try { + // Create AES-GCM cipher with the KEK + // Tink's AesGcmJce automatically generates a random IV and prepends it to the ciphertext + AesGcmJce aesgcm = new AesGcmJce(kekBytes); + + // 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 + ); + + logger.debug("Wrapped {} key with KEK {}", purpose, kekLabel); + return wrapped; + + } catch (Exception e) { + throw KMSException.wrapUnwrapFailed("Failed to wrap key: " + e.getMessage(), e); + } finally { + // Zeroize KEK + Arrays.fill(kekBytes, (byte) 0); + } + } + + @Override + public byte[] unwrapKey(WrappedKey wrappedKey) throws KMSException { + if (wrappedKey == null) { + throw KMSException.invalidParameter("Wrapped key cannot be null"); + } + + byte[] kekBytes = loadKek(wrappedKey.getKekId()); + + try { + // Create AES-GCM cipher with the KEK + AesGcmJce aesgcm = new AesGcmJce(kekBytes); + + // Tink's decrypt expects [IV][ciphertext+tag] format (same as encrypt returns) + byte[] blob = wrappedKey.getWrappedKeyMaterial(); + if (blob.length < GCM_IV_LENGTH + GCM_TAG_LENGTH) { + throw new KMSException(KMSException.ErrorType.WRAP_UNWRAP_FAILED, + "Invalid wrapped key format: too short"); + } + + // Decrypt the DEK (Tink extracts IV from the blob automatically) + byte[] plainKey = aesgcm.decrypt(blob, new byte[0]); // Empty associated data + + logger.debug("Unwrapped {} key with KEK {}", wrappedKey.getPurpose(), wrappedKey.getKekId()); + return plainKey; + + } catch (KMSException e) { + throw e; + } catch (Exception e) { + throw KMSException.wrapUnwrapFailed("Failed to unwrap key: " + e.getMessage(), e); + } finally { + // Zeroize KEK + Arrays.fill(kekBytes, (byte) 0); + } + } + + @Override + public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits) throws KMSException { + if (keyBits != 128 && keyBits != 192 && keyBits != 256) { + throw KMSException.invalidParameter("DEK size must be 128, 192, or 256 bits"); + } + + // Generate random DEK + byte[] dekBytes = new byte[keyBits / 8]; + secureRandom.nextBytes(dekBytes); + + try { + return wrapKey(dekBytes, purpose, kekLabel); + } finally { + // Zeroize DEK (wrapped version is in WrappedKey) + Arrays.fill(dekBytes, (byte) 0); + } + } + + @Override + public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel) throws KMSException { + // Unwrap with old KEK + byte[] plainKey = unwrapKey(oldWrappedKey); + + try { + // Wrap with new KEK + return wrapKey(plainKey, oldWrappedKey.getPurpose(), newKekLabel); + } finally { + // Zeroize plaintext DEK + Arrays.fill(plainKey, (byte) 0); + } + } + + @Override + public boolean healthCheck() throws KMSException { + try { + // Verify we can access configuration + if (configDao == null) { + logger.error("Configuration DAO 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) { + throw KMSException.healthCheckFailed("Health check failed: " + e.getMessage(), e); + } + } + + // ==================== Private Helper Methods ==================== + + private byte[] loadKek(String kekLabel) throws KMSException { + // Check cache first + if (CacheEnabled.value()) { + byte[] cached = kekCache.get(kekLabel); + if (cached != null) { + return Arrays.copyOf(cached, cached.length); // Return copy + } + } + + // Load from database + String configKey = buildConfigKey(kekLabel); + ConfigurationVO config = configDao.findByName(configKey); + + if (config == null) { + throw KMSException.kekNotFound("KEK with label " + kekLabel + " not found"); + } + + try { + // getValue() automatically decrypts + String kekBase64 = config.getValue(); + if (StringUtils.isEmpty(kekBase64)) { + throw KMSException.kekNotFound("KEK value is empty for label " + kekLabel); + } + + byte[] kekBytes = java.util.Base64.getDecoder().decode(kekBase64); + + // Cache for future use + if (CacheEnabled.value()) { + kekCache.put(kekLabel, Arrays.copyOf(kekBytes, kekBytes.length)); + } + + return kekBytes; + + } catch (IllegalArgumentException e) { + throw KMSException.kekOperationFailed("Invalid KEK encoding for label " + kekLabel, e); + } + } + + private String buildConfigKey(String label) { + return KEK_CONFIG_PREFIX + label; + } + + private String generateKekLabel(KeyPurpose purpose) { + return purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); + } +} + 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 new file mode 100644 index 00000000000..57d436bcea5 --- /dev/null +++ b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties @@ -0,0 +1,20 @@ +# 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=database-kms +parent=kmsProvidersRegistry + 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 new file mode 100644 index 00000000000..be2e666a74d --- /dev/null +++ b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml @@ -0,0 +1,36 @@ + + + + + + + + + diff --git a/plugins/kms/pom.xml b/plugins/kms/pom.xml new file mode 100644 index 00000000000..afff4024e96 --- /dev/null +++ b/plugins/kms/pom.xml @@ -0,0 +1,39 @@ + + + + 4.0.0 + cloudstack-kms-plugins + pom + Apache CloudStack Plugin - KMS + Key Management Service providers + + + org.apache.cloudstack + cloudstack-plugins + 4.23.0.0-SNAPSHOT + ../pom.xml + + + + database + + diff --git a/plugins/pom.xml b/plugins/pom.xml index e7d13871285..4b4aae9479c 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -97,6 +97,8 @@ integrations/prometheus integrations/kubernetes-service + kms + metrics network-elements/bigswitch diff --git a/server/pom.xml b/server/pom.xml index 2b35a0f42ac..a44c3af0e73 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -69,6 +69,11 @@ cloud-framework-ca ${project.version} + + org.apache.cloudstack + cloud-framework-kms + ${project.version} + org.apache.cloudstack cloud-framework-jobs diff --git a/server/src/main/java/com/cloud/api/ApiResponseHelper.java b/server/src/main/java/com/cloud/api/ApiResponseHelper.java index cf98df0da24..10b77c9fbbf 100644 --- a/server/src/main/java/com/cloud/api/ApiResponseHelper.java +++ b/server/src/main/java/com/cloud/api/ApiResponseHelper.java @@ -115,6 +115,7 @@ import org.apache.cloudstack.api.response.HypervisorGuestOsResponse; import org.apache.cloudstack.api.response.IPAddressResponse; import org.apache.cloudstack.api.response.ImageStoreResponse; import org.apache.cloudstack.api.response.InstanceGroupResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.InternalLoadBalancerElementResponse; import org.apache.cloudstack.api.response.IpForwardingRuleResponse; import org.apache.cloudstack.api.response.IpQuarantineResponse; @@ -424,6 +425,7 @@ import com.cloud.user.AccountManager; import com.cloud.user.SSHKeyPair; import com.cloud.user.User; import com.cloud.user.UserAccount; +import org.apache.cloudstack.kms.KMSKey; import com.cloud.user.UserData; import com.cloud.user.UserStatisticsVO; import com.cloud.user.dao.UserDataDao; @@ -5802,4 +5804,48 @@ protected Map getResourceIconsUsingOsCategory(List kmsProviderMap = new HashMap<>(); + private static KMSProvider configuredKmsProvider; + @Inject + private KMSWrappedKeyDao kmsWrappedKeyDao; + @Inject + private KMSKeyDao kmsKeyDao; + @Inject + private KMSKekVersionDao kmsKekVersionDao; + @Inject + private AccountManager accountManager; + @Inject + private ResponseGenerator responseGenerator; + private List kmsProviders; + + // ==================== Provider Management ==================== + + @Override + public List listKMSProviders() { + return kmsProviders; + } + + @Override + public KMSProvider getKMSProvider(String name) { + if (StringUtils.isEmpty(name)) { + return getConfiguredKmsProvider(); + } + + String providerName = name.toLowerCase(); + if (!kmsProviderMap.containsKey(providerName)) { + throw new CloudRuntimeException(String.format("KMS provider '%s' not found", providerName)); + } + + KMSProvider provider = kmsProviderMap.get(providerName); + if (provider == null) { + throw new CloudRuntimeException(String.format("KMS provider '%s' returned is null", providerName)); + } + + return provider; + } + + @Override + public KMSProvider getKMSProviderForZone(Long zoneId) throws KMSException { + // For now, use global provider + // In future, could support zone-specific providers via zone-scoped config + return getConfiguredKmsProvider(); + } + + @Override + public boolean isKmsEnabled(Long zoneId) { + if (zoneId == null) { + return false; + } + return KMSEnabled.valueIn(zoneId); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_CREATE, eventDescription = "creating KEK", async = false) + public String createKek(Long zoneId, KeyPurpose purpose, String label, int keyBits) throws KMSException { + validateKmsEnabled(zoneId); + + KMSProvider provider = getKMSProviderForZone(zoneId); + + try { + logger.info("Creating KEK for zone {} with purpose {} and {} bits", zoneId, purpose, keyBits); + return retryOperation(() -> provider.createKek(purpose, label, keyBits)); + } catch (Exception e) { + logger.error("Failed to create KEK for zone {}: {}", zoneId, e.getMessage()); + throw handleKmsException(e); + } + } + + // ==================== KEK Management ==================== + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_DELETE, eventDescription = "deleting KEK", async = false) + 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); + + try { + logger.warn("Deleting KEK {} for zone {}", kekId, zoneId); + retryOperation(() -> { + provider.deleteKek(kekId); + return null; + }); + } catch (Exception e) { + logger.error("Failed to delete KEK {} for zone {}: {}", kekId, zoneId, e.getMessage()); + throw handleKmsException(e); + } + } + + @Override + public List listKeks(Long zoneId, KeyPurpose purpose) throws KMSException { + validateKmsEnabled(zoneId); + + KMSProvider provider = getKMSProviderForZone(zoneId); + + try { + return retryOperation(() -> provider.listKeks(purpose)); + } catch (Exception e) { + logger.error("Failed to list KEKs for zone {}: {}", zoneId, e.getMessage()); + throw handleKmsException(e); + } + } + + @Override + public boolean isKekAvailable(Long zoneId, String kekId) throws KMSException { + if (!isKmsEnabled(zoneId)) { + return false; + } + + try { + KMSProvider provider = getKMSProviderForZone(zoneId); + return provider.isKekAvailable(kekId); + } catch (Exception e) { + logger.warn("Error checking KEK availability: {}", e.getMessage()); + return false; + } + } + + @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); + + if (StringUtils.isEmpty(oldKekLabel)) { + throw KMSException.invalidParameter("oldKekLabel must be specified"); + } + + KMSProvider provider = getKMSProviderForZone(zoneId); + + try { + logger.info("Starting KEK rotation from {} to {} for zone {} and purpose {}", + oldKekLabel, newKekLabel, zoneId, purpose); + + // Find KMS key by old KEK label + KMSKeyVO kmsKey = kmsKeyDao.findByKekLabel(oldKekLabel, provider.getProviderName()); + if (kmsKey == null) { + throw KMSException.kekNotFound("KMS key not found for KEK label: " + oldKekLabel); + } + + // Generate new KEK label if not provided + if (StringUtils.isEmpty(newKekLabel)) { + newKekLabel = purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); + } + + // Create new KEK in provider + String newKekId = provider.createKek(purpose, newKekLabel, keyBits); + + // Create new KEK version (marks old as Previous, new as Active) + 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(), + newVersion.getVersionNumber() - 1); + + // TODO: Schedule background job to rewrap all DEKs (Phase 5) + // This will gradually rewrap wrapped keys to use the new KEK version + + return newKekId; + + } catch (Exception e) { + logger.error("KEK rotation failed for zone {}: {}", zoneId, e.getMessage()); + throw handleKmsException(e); + } + } + + // ==================== DEK Operations ==================== + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEY_UNWRAP, eventDescription = "unwrapping volume key", async = false) + 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); + + try { + logger.debug("Unwrapping {} key", wrappedKey.getPurpose()); + return retryOperation(() -> provider.unwrapKey(wrappedKey)); + } catch (Exception e) { + logger.error("Failed to unwrap key: {}", e.getMessage()); + throw handleKmsException(e); + } + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_HEALTH_CHECK, eventDescription = "KMS health check", async = false) + public boolean healthCheck(Long zoneId) throws KMSException { + if (!isKmsEnabled(zoneId)) { + logger.debug("KMS is not enabled for zone {}", zoneId); + return false; + } + + try { + KMSProvider provider = getKMSProviderForZone(zoneId); + return provider.healthCheck(); + } catch (Exception e) { + logger.error("Health check failed for zone {}: {}", zoneId, e.getMessage()); + throw handleKmsException(e); + } + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_CREATE, eventDescription = "creating user KMS key", async = false) + public KMSKey createUserKMSKey(Long accountId, Long domainId, Long zoneId, + String name, String description, KeyPurpose purpose, + Integer keyBits) throws KMSException { + validateKmsEnabled(zoneId); + + KMSProvider provider = getKMSProviderForZone(zoneId); + + // Generate unique KEK label + String kekLabel = purpose.getName() + "-kek-" + UUID.randomUUID().toString().substring(0, 8); + + // Create KEK in provider + String providerKekLabel; + try { + providerKekLabel = retryOperation(() -> provider.createKek(purpose, kekLabel, keyBits)); + } catch (Exception e) { + throw handleKmsException(e); + } + + // Create metadata entry + KMSKeyVO kmsKey = new KMSKeyVO(name, description, providerKekLabel, purpose, + accountId, domainId, zoneId, provider.getProviderName(), + "AES/GCM/NoPadding", keyBits); + kmsKey = kmsKeyDao.persist(kmsKey); + + // Create initial KEK version (version 1, status=Active) + KMSKekVersionVO initialVersion = new KMSKekVersionVO(kmsKey.getId(), 1, providerKekLabel, + 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); + 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)) { + return null; + } + return key; + } + + // ==================== Helper Methods ==================== + + @Override + public boolean hasPermission(Long callerAccountId, String keyUuid) { + KMSKeyVO key = kmsKeyDao.findByUuid(keyUuid); + 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; + } + + @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); + } + + // Check permission + if (!hasPermission(callerAccountId, uuid)) { + throw KMSException.invalidParameter("No permission to delete KMS key: " + uuid); + } + + // Check if key is in use + long wrappedKeyCount = kmsKeyDao.countWrappedKeysByKmsKey(key.getId()); + if (wrappedKeyCount > 0) { + throw KMSException.invalidParameter("Cannot delete KMS key: " + wrappedKeyCount + + " wrapped key(s) still reference this key"); + } + + // Soft delete + key.setState(KMSKey.State.Deleted); + key.setRemoved(new java.util.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); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEK_CREATE, eventDescription = "updating user KMS key", async = false) + public KMSKey updateUserKMSKey(String uuid, 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); + } + + boolean updated = false; + if (name != null && !name.equals(key.getName())) { + key.setName(name); + updated = true; + } + if (description != null && !description.equals(key.getDescription())) { + key.setDescription(description); + updated = true; + } + if (state != null && state != key.getState()) { + if (state == KMSKey.State.Deleted) { + throw KMSException.invalidParameter("Cannot set state to Deleted. Use deleteKMSKey instead."); + } + key.setState(state); + updated = true; + } + + if (updated) { + kmsKeyDao.update(key.getId(), key); + logger.info("Updated KMS key '{}' (UUID: {})", key.getName(), uuid); + } + + return key; + } + + /** + * Unwrap a DEK by wrapped key ID, trying multiple KEK versions if needed + */ + @Override + public byte[] unwrapKey(Long wrappedKeyId) throws KMSException { + KMSWrappedKeyVO wrappedVO = kmsWrappedKeyDao.findById(wrappedKeyId); + if (wrappedVO == null) { + throw KMSException.kekNotFound("Wrapped key not found: " + wrappedKeyId); + } + + KMSKeyVO kmsKey = kmsKeyDao.findById(wrappedVO.getKmsKeyId()); + if (kmsKey == null) { + throw KMSException.kekNotFound("KMS key not found for wrapped key: " + wrappedKeyId); + } + + KMSProvider provider = getKMSProvider(kmsKey.getProviderName()); + + // Try the specific version first if available + if (wrappedVO.getKekVersionId() != null) { + KMSKekVersionVO version = kmsKekVersionDao.findById(wrappedVO.getKekVersionId()); + if (version != null && version.getStatus() != KMSKekVersionVO.Status.Archived) { + try { + WrappedKey wrapped = new WrappedKey(version.getKekLabel(), kmsKey.getPurpose(), + kmsKey.getAlgorithm(), wrappedVO.getWrappedBlob(), + kmsKey.getProviderName(), wrappedVO.getCreated(), kmsKey.getZoneId()); + byte[] dek = retryOperation(() -> provider.unwrapKey(wrapped)); + logger.debug("Successfully unwrapped key {} with KEK version {}", wrappedKeyId, + version.getVersionNumber()); + return dek; + } catch (Exception e) { + logger.warn("Failed to unwrap with version {}: {}", version.getVersionNumber(), e.getMessage()); + } + } + } + + // Fallback: try all available versions for decryption + List versions = getKekVersionsForDecryption(kmsKey.getId()); + for (KMSKekVersionVO version : versions) { + try { + WrappedKey wrapped = new WrappedKey(version.getKekLabel(), kmsKey.getPurpose(), + kmsKey.getAlgorithm(), wrappedVO.getWrappedBlob(), + kmsKey.getProviderName(), wrappedVO.getCreated(), kmsKey.getZoneId()); + byte[] dek = retryOperation(() -> provider.unwrapKey(wrapped)); + logger.info("Successfully unwrapped key {} with KEK version {} (fallback)", wrappedKeyId, + version.getVersionNumber()); + return dek; + } catch (Exception e) { + logger.debug("Failed to unwrap with version {}: {}", version.getVersionNumber(), e.getMessage()); + } + } + + throw KMSException.wrapUnwrapFailed("Failed to unwrap key with any available KEK version"); + } + + // ==================== 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 { + // Get and validate KMS key + KMSKey kmsKey = getUserKMSKey(kekUuid, callerAccountId); + if (kmsKey == null) { + throw KMSException.kekNotFound("KMS key not found or no permission: " + kekUuid); + } + + if (kmsKey.getState() != KMSKey.State.Enabled) { + throw KMSException.invalidParameter("KMS key is not enabled: " + kekUuid); + } + + if (kmsKey.getPurpose() != KeyPurpose.VOLUME_ENCRYPTION) { + throw KMSException.invalidParameter("KMS key purpose is not VOLUME_ENCRYPTION: " + kekUuid); + } + + // Get provider + KMSProvider provider = getKMSProviderForZone(kmsKey.getZoneId()); + + // Get active KEK version + KMSKekVersionVO activeVersion = getActiveKekVersion(kmsKey.getId()); + + // Generate and wrap DEK using active KEK version + int dekSize = KMSDekSizeBits.value(); + WrappedKey wrappedKey; + try { + wrappedKey = retryOperation(() -> + provider.generateAndWrapDek(KeyPurpose.VOLUME_ENCRYPTION, activeVersion.getKekLabel(), dekSize)); + // Store the wrapped key in database + KMSWrappedKeyVO wrappedKeyVO = new KMSWrappedKeyVO(kmsKey.getId(), activeVersion.getId(), + kmsKey.getZoneId(), wrappedKey.getWrappedKeyMaterial()); + wrappedKeyVO = kmsWrappedKeyDao.persist(wrappedKeyVO); + + // 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( + wrappedKeyVO.getUuid(), + wrappedKey.getKekId(), + wrappedKey.getPurpose(), + wrappedKey.getAlgorithm(), + wrappedKey.getWrappedKeyMaterial(), + wrappedKey.getProviderName(), + 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()); + return wrappedKey; + } + + /** + * Get the active KEK version for a KMS key + */ + private KMSKekVersionVO getActiveKekVersion(Long kmsKeyId) throws KMSException { + KMSKekVersionVO activeVersion = kmsKekVersionDao.getActiveVersion(kmsKeyId); + if (activeVersion == null) { + throw KMSException.kekNotFound("No active KEK version found for KMS key ID: " + kmsKeyId); + } + return activeVersion; + } + + // ==================== Configurable Implementation ==================== + + @Override + public KMSKeyResponse createKMSKey(CreateKMSKeyCmd cmd) throws KMSException { + 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()) && + !accountManager.isDomainAdmin(caller.getId())) { + throw new ServerApiException(ApiErrorCode.UNAUTHORIZED, + "Only admins and domain admins can create keys for other accounts"); + } + + if (cmd.getAccountName() != null && cmd.getDomainId() != null) { + targetAccount = accountManager.getActiveAccountByName(cmd.getAccountName(), cmd.getDomainId()); + if (targetAccount == null) { + 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"); + } + } + + // Validate purpose + KeyPurpose keyPurpose; + try { + keyPurpose = KeyPurpose.fromString(cmd.getPurpose()); + } catch (IllegalArgumentException e) { + throw KMSException.invalidParameter("Invalid purpose: " + cmd.getPurpose() + + ". Valid values: VOLUME_ENCRYPTION, TLS_CERT, CONFIG_SECRET"); + } + + // Validate key bits + int bits = cmd.getKeyBits(); + if (bits != 128 && bits != 192 && bits != 256) { + throw KMSException.invalidParameter("Key bits must be 128, 192, or 256"); + } + + // Create the KMS key + KMSKey kmsKey = createUserKMSKey( + targetAccount.getId(), + targetAccount.getDomainId(), + cmd.getZoneId(), + cmd.getName(), + cmd.getDescription(), + keyPurpose, + bits + ); + + return responseGenerator.createKMSKeyResponse(kmsKey); + } + + // ==================== KEK Version Management ==================== + + @Override + 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; + } + + // 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 + } + } + + // 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 + } + } + + // 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 + ListResponse listResponse = new ListResponse<>(); + listResponse.setResponses(new java.util.ArrayList<>(), 0); + 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; + } + + // List accessible keys + List keys = listUserKMSKeys( + caller.getId(), + caller.getDomainId(), + cmd.getZoneId(), + keyPurpose, + keyState + ); + + List responses = new java.util.ArrayList<>(); + for (KMSKey key : keys) { + responses.add(responseGenerator.createKMSKeyResponse(key)); + } + + ListResponse listResponse = new ListResponse<>(); + listResponse.setResponses(responses, responses.size()); + return listResponse; + } + + @Override + 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"); + } + } + + // 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, + cmd.getName(), cmd.getDescription(), keyState); + return responseGenerator.createKMSKeyResponse(updatedKey); + } + + @Override + 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; + } + + // ==================== User KEK Management ==================== + + /** + * Create a new KEK version for a KMS key + */ + private KMSKekVersionVO createKekVersion(Long kmsKeyId, String kekLabel, int keyBits) throws KMSException { + // Get existing versions to determine next version number + List existingVersions = kmsKekVersionDao.listByKmsKeyId(kmsKeyId); + int nextVersion = existingVersions.stream() + .mapToInt(KMSKekVersionVO::getVersionNumber) + .max() + .orElse(0) + 1; + + // Mark current active version as Previous + KMSKekVersionVO currentActive = kmsKekVersionDao.getActiveVersion(kmsKeyId); + if (currentActive != null) { + currentActive.setStatus(KMSKekVersionVO.Status.Previous); + kmsKekVersionDao.update(currentActive.getId(), currentActive); + } + + // Create new active version + KMSKekVersionVO newVersion = new KMSKekVersionVO(kmsKeyId, nextVersion, kekLabel, + KMSKekVersionVO.Status.Active); + newVersion = kmsKekVersionDao.persist(newVersion); + + logger.info("Created KEK version {} for KMS key {} (label: {})", nextVersion, kmsKeyId, kekLabel); + return newVersion; + } + + private void validateKmsEnabled(Long zoneId) throws KMSException { + if (zoneId == null) { + throw KMSException.invalidParameter("Zone ID cannot be null"); + } + + if (!isKmsEnabled(zoneId)) { + throw KMSException.providerNotInitialized( + "KMS is not enabled for zone " + zoneId + ". Set kms.enabled=true for this zone."); + } + } + + private T retryOperation(KmsOperation operation) throws Exception { + int maxRetries = KMSRetryCount.value(); + int retryDelay = KMSRetryDelayMs.value(); + + Exception lastException = null; + + for (int attempt = 0; attempt <= maxRetries; attempt++) { + try { + return operation.execute(); + } catch (Exception e) { + lastException = e; + + // Check if retryable + if (e instanceof KMSException && !((KMSException) e).isRetryable()) { + throw e; + } + + if (attempt < maxRetries) { + logger.warn("KMS operation failed (attempt {}/{}): {}. Retrying...", + attempt + 1, maxRetries + 1, e.getMessage()); + + try { + Thread.sleep((long) retryDelay * (attempt + 1)); // Exponential backoff + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new CloudRuntimeException("Interrupted during retry", ie); + } + } else { + logger.error("KMS operation failed after {} attempts", maxRetries + 1); + } + } + } + + if (lastException != null) { + throw lastException; + } + + throw new CloudRuntimeException("KMS operation failed with no exception details"); + } + + private KMSException handleKmsException(Exception e) { + if (e instanceof KMSException) { + return (KMSException) e; + } + return KMSException.transientError("KMS operation failed: " + e.getMessage(), e); + } + + private KMSProvider getConfiguredKmsProvider() { + if (configuredKmsProvider != null) { + return configuredKmsProvider; + } + + String providerName = KMSProviderPlugin.value(); + if (kmsProviderMap.containsKey(providerName) && kmsProviderMap.get(providerName) != null) { + configuredKmsProvider = kmsProviderMap.get(providerName); + return configuredKmsProvider; + } + + throw new CloudRuntimeException("Failed to find default configured KMS provider plugin: " + providerName); + } + + public void setKmsProviders(List kmsProviders) { + this.kmsProviders = kmsProviders; + initializeKmsProviderMap(); + } + + // ==================== API Response Methods ==================== + + private void initializeKmsProviderMap() { + if (kmsProviderMap != null && kmsProviderMap.size() != kmsProviders.size()) { + for (KMSProvider provider : kmsProviders) { + kmsProviderMap.put(provider.getProviderName().toLowerCase(), provider); + logger.info("Registered KMS provider: {}", provider.getProviderName()); + } + } + } + + @Override + public boolean start() { + super.start(); + initializeKmsProviderMap(); + + String configuredProviderName = KMSProviderPlugin.value(); + if (kmsProviderMap.containsKey(configuredProviderName)) { + configuredKmsProvider = kmsProviderMap.get(configuredProviderName); + logger.info("Configured KMS provider: {}", configuredKmsProvider.getProviderName()); + } + + if (configuredKmsProvider == null) { + logger.warn("No valid configured KMS provider found. KMS functionality will be unavailable."); + // Don't fail - KMS is optional + return true; + } + + // Run health check on startup + try { + boolean healthy = configuredKmsProvider.healthCheck(); + if (healthy) { + logger.info("KMS provider {} health check passed", configuredKmsProvider.getProviderName()); + } else { + logger.warn("KMS provider {} health check failed", configuredKmsProvider.getProviderName()); + } + } catch (Exception e) { + logger.warn("KMS provider health check error: {}", e.getMessage()); + } + + return true; + } + + @Override + public String getConfigComponentName() { + return KMSManager.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[]{ + KMSProviderPlugin, + KMSEnabled, + KMSDekSizeBits, + KMSRetryCount, + KMSRetryDelayMs, + KMSOperationTimeoutSec + }; + } + + @Override + public List> getCommands() { + List> cmdList = new ArrayList<>(); + cmdList.add(ListKMSKeysCmd.class); + cmdList.add(CreateKMSKeyCmd.class); + cmdList.add(UpdateKMSKeyCmd.class); + cmdList.add(DeleteKMSKeyCmd.class); + + return cmdList; + } + + @FunctionalInterface + private interface KmsOperation { + T execute() throws Exception; + } +} + diff --git a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml index 37d32c0f390..6c38429cf7b 100644 --- a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml +++ b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml @@ -330,6 +330,11 @@ + + + + + diff --git a/tools/apidoc/gen_toc.py b/tools/apidoc/gen_toc.py index e41a04ff2e1..243fd9eeb57 100644 --- a/tools/apidoc/gen_toc.py +++ b/tools/apidoc/gen_toc.py @@ -56,6 +56,7 @@ known_categories = { 'HypervisorGuestOsNames': 'Guest OS', 'Domain': 'Domain', 'Template': 'Template', + 'KMS': 'KMS', 'Iso': 'ISO', 'Volume': 'Volume', 'Vlan': 'VLAN',