Skip to content

Commit 90b18fc

Browse files
committed
Fix KMS issues
1 parent d3f0700 commit 90b18fc

23 files changed

Lines changed: 1642 additions & 210 deletions

File tree

‎api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/CreateHSMProfileCmd.java‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ public class CreateHSMProfileCmd extends BaseCmd {
8787
private String vendorName;
8888

8989
@Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP,
90-
description = "HSM configuration details (protocol specific)")
90+
description = "HSM configuration details (protocol specific). For pkcs11, the wrap_algorithm detail "
91+
+ "selects how volume encryption keys get wrapped. Allowed values are AES/GCM/NoPadding "
92+
+ "(default), AES/KWP/NoPadding (needs Java 18 or later) and AES/CBC/PKCS5Padding. "
93+
+ "It can't be changed later.")
9194
private Map<String, String> details;
9295

9396
public String getName() {

‎engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/VolumeService.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ public VolumeInfo getVolume() {
7272
*/
7373
AsyncCallFuture<VolumeApiResult> expungeVolumeAsync(VolumeInfo volume);
7474

75+
/**
76+
* Releases the encryption key (KMS wrapped key or legacy passphrase) of removed or expunged volumes once nothing
77+
* needs it anymore, e.g. after the last snapshot of such a volume was deleted.
78+
*/
79+
void releaseKeysOfRemovedVolumes();
80+
7581
void ensureVolumeIsExpungeReady(long volumeId);
7682

7783
boolean cloneVolume(long volumeId, long baseVolId);

‎engine/schema/src/main/java/com/cloud/storage/dao/VolumeDao.java‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,27 @@ public interface VolumeDao extends GenericDao<VolumeVO, Long>, StateDao<Volume.S
191191
VolumeVO findByExternalUuid(String externalUuid);
192192

193193
List<VolumeVO> findByKmsWrappedKeyId(Long kmsWrappedKeyId);
194+
195+
/**
196+
* All volumes that reference the given KMS wrapped key, including removed ones. Snapshots of a removed volume
197+
* still need the key of that volume.
198+
*/
199+
List<VolumeVO> listIncludingRemovedByKmsWrappedKeyId(long kmsWrappedKeyId);
200+
201+
/**
202+
* All volumes that reference the given passphrase, including removed ones.
203+
*/
204+
List<VolumeVO> listIncludingRemovedByPassphraseId(long passphraseId);
205+
206+
/**
207+
* Removed or expunged volumes whose KMS wrapped key has not been removed yet. Their key may no longer be needed
208+
* once their snapshots are gone; used by the storage garbage collector to release it.
209+
*/
210+
List<VolumeVO> listRemovedOrExpungedWithKmsWrappedKey();
211+
212+
/**
213+
* Removed or expunged volumes that still reference a passphrase. Used by the storage garbage collector to release
214+
* the passphrase once it is no longer needed.
215+
*/
216+
List<VolumeVO> listRemovedOrExpungedWithPassphrase();
194217
}

‎engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java‎

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@
2323
import java.util.Collections;
2424
import java.util.Date;
2525
import java.util.List;
26+
import java.util.Map;
2627
import java.util.stream.Collectors;
2728

2829
import javax.inject.Inject;
30+
import javax.naming.ConfigurationException;
2931

32+
import org.apache.cloudstack.kms.KMSWrappedKeyVO;
33+
import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao;
3034
import org.apache.cloudstack.reservation.ReservationVO;
3135
import org.apache.cloudstack.reservation.dao.ReservationDao;
32-
import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao;
3336
import org.apache.commons.collections.CollectionUtils;
3437
import org.springframework.stereotype.Component;
3538

@@ -50,6 +53,7 @@
5053
import com.cloud.utils.db.Filter;
5154
import com.cloud.utils.db.GenericDaoBase;
5255
import com.cloud.utils.db.GenericSearchBuilder;
56+
import com.cloud.utils.db.JoinBuilder;
5357
import com.cloud.utils.db.QueryBuilder;
5458
import com.cloud.utils.db.SearchBuilder;
5559
import com.cloud.utils.db.SearchCriteria;
@@ -82,6 +86,8 @@ public class VolumeDaoImpl extends GenericDaoBase<VolumeVO, Long> implements Vol
8286
private final SearchBuilder<VolumeVO> poolAndPathSearch;
8387
final GenericSearchBuilder<VolumeVO, Integer> CountByOfferingId;
8488
private final SearchBuilder<VolumeVO> kmsMigrationSearch;
89+
private SearchBuilder<VolumeVO> removedWithPassphraseSearch;
90+
private SearchBuilder<VolumeVO> removedWithLiveKmsWrappedKeySearch;
8591

8692
@Inject
8793
ReservationDao reservationDao;
@@ -543,6 +549,32 @@ public VolumeDaoImpl() {
543549
kmsMigrationSearch.done();
544550
}
545551

552+
@Override
553+
public boolean configure(String name, Map<String, Object> params) throws ConfigurationException {
554+
boolean result = super.configure(name, params);
555+
556+
removedWithPassphraseSearch = createSearchBuilder();
557+
removedWithPassphraseSearch.and("passphraseId", removedWithPassphraseSearch.entity().getPassphraseId(), Op.NNULL);
558+
removedWithPassphraseSearch.and().op("removed", removedWithPassphraseSearch.entity().getRemoved(), Op.NNULL);
559+
removedWithPassphraseSearch.or("state", removedWithPassphraseSearch.entity().getState(), Op.EQ);
560+
removedWithPassphraseSearch.cp();
561+
removedWithPassphraseSearch.done();
562+
563+
// Joins with the table of another DAO, so it is built here rather than in the constructor.
564+
SearchBuilder<KMSWrappedKeyVO> liveWrappedKeySearch = kmsWrappedKeyDao.createSearchBuilder();
565+
liveWrappedKeySearch.and("wrappedKeyRemoved", liveWrappedKeySearch.entity().getRemoved(), Op.NULL);
566+
567+
removedWithLiveKmsWrappedKeySearch = createSearchBuilder();
568+
removedWithLiveKmsWrappedKeySearch.and().op("removed", removedWithLiveKmsWrappedKeySearch.entity().getRemoved(), Op.NNULL);
569+
removedWithLiveKmsWrappedKeySearch.or("state", removedWithLiveKmsWrappedKeySearch.entity().getState(), Op.EQ);
570+
removedWithLiveKmsWrappedKeySearch.cp();
571+
removedWithLiveKmsWrappedKeySearch.join("wrappedKey", liveWrappedKeySearch, liveWrappedKeySearch.entity().getId(),
572+
removedWithLiveKmsWrappedKeySearch.entity().getKmsWrappedKeyId(), JoinBuilder.JoinType.INNER);
573+
removedWithLiveKmsWrappedKeySearch.done();
574+
575+
return result;
576+
}
577+
546578
@Override
547579
@DB()
548580
public Pair<Long, Long> getCountAndTotalByPool(long poolId) {
@@ -784,17 +816,6 @@ public boolean remove(Long id) {
784816
logger.debug(String.format("Removing volume %s from DB", id));
785817
VolumeVO entry = findById(id);
786818
if (entry != null) {
787-
// Clean up KMS wrapped key if volume was encrypted with KMS
788-
if (entry.getKmsWrappedKeyId() != null) {
789-
try {
790-
kmsWrappedKeyDao.remove(entry.getKmsWrappedKeyId());
791-
logger.debug("Removed KMS wrapped key [id={}] for volume [id={}, uuid={}]",
792-
entry.getKmsWrappedKeyId(), id, entry.getUuid());
793-
} catch (Exception e) {
794-
logger.warn("Failed to remove KMS wrapped key [id={}] for volume [id={}, uuid={}]: {}",
795-
entry.getKmsWrappedKeyId(), id, entry.getUuid(), e.getMessage(), e);
796-
}
797-
}
798819
tagsDao.removeByIdAndType(id, ResourceObjectType.Volume);
799820
}
800821
boolean result = super.remove(id);
@@ -1011,4 +1032,32 @@ public List<VolumeVO> findByKmsWrappedKeyId(Long kmsWrappedKeyId) {
10111032
sc.setParameters("notDestroyed", Volume.State.Expunged);
10121033
return listBy(sc);
10131034
}
1035+
1036+
@Override
1037+
public List<VolumeVO> listIncludingRemovedByKmsWrappedKeyId(long kmsWrappedKeyId) {
1038+
SearchCriteria<VolumeVO> sc = AllFieldsSearch.create();
1039+
sc.setParameters("kmsWrappedKeyId", kmsWrappedKeyId);
1040+
return listIncludingRemovedBy(sc);
1041+
}
1042+
1043+
@Override
1044+
public List<VolumeVO> listIncludingRemovedByPassphraseId(long passphraseId) {
1045+
SearchCriteria<VolumeVO> sc = AllFieldsSearch.create();
1046+
sc.setParameters("passphraseId", passphraseId);
1047+
return listIncludingRemovedBy(sc);
1048+
}
1049+
1050+
@Override
1051+
public List<VolumeVO> listRemovedOrExpungedWithKmsWrappedKey() {
1052+
SearchCriteria<VolumeVO> sc = removedWithLiveKmsWrappedKeySearch.create();
1053+
sc.setParameters("state", Volume.State.Expunged);
1054+
return listIncludingRemovedBy(sc);
1055+
}
1056+
1057+
@Override
1058+
public List<VolumeVO> listRemovedOrExpungedWithPassphrase() {
1059+
SearchCriteria<VolumeVO> sc = removedWithPassphraseSearch.create();
1060+
sc.setParameters("state", Volume.State.Expunged);
1061+
return listIncludingRemovedBy(sc);
1062+
}
10141063
}

‎engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42300to2400.java‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,27 @@
1616
// under the License.
1717
package com.cloud.upgrade.dao;
1818

19+
import java.sql.Connection;
20+
import java.sql.PreparedStatement;
21+
import java.sql.ResultSet;
22+
import java.sql.SQLException;
23+
24+
import com.cloud.utils.exception.CloudRuntimeException;
25+
1926
public class Upgrade42300to2400 extends DbUpgradeAbstractImpl implements DbUpgrade, DbUpgradeSystemVmTemplate {
2027

28+
// A removed KMS wrapped key that a volume (not expunged) or a snapshot (not destroyed) still needs
29+
private static final String REMOVED_KMS_WRAPPED_KEY_IN_USE = "w.removed IS NOT NULL AND ("
30+
+ "EXISTS (SELECT 1 FROM `cloud`.`volumes` v WHERE v.kms_wrapped_key_id = w.id"
31+
+ " AND v.removed IS NULL AND v.state <> 'Expunged')"
32+
+ " OR EXISTS (SELECT 1 FROM `cloud`.`snapshots` s JOIN `cloud`.`volumes` v ON v.id = s.volume_id"
33+
+ " WHERE v.kms_wrapped_key_id = w.id AND s.removed IS NULL AND s.status <> 'Destroyed'))";
34+
// The KMS key and the KEK the key was wrapped with still exist. Deleting a KMS key deletes its KEKs but leaves
35+
// its version rows as they are.
36+
private static final String KEK_VERSION_USABLE = "EXISTS (SELECT 1 FROM `cloud`.`kms_kek_versions` k"
37+
+ " JOIN `cloud`.`kms_keys` kk ON kk.id = k.kms_key_id"
38+
+ " WHERE k.id = w.kek_version_id AND k.removed IS NULL AND k.status <> 'Archived' AND kk.removed IS NULL)";
39+
2140
@Override
2241
public String[] getUpgradableVersionRange() {
2342
return new String[]{"4.23.0.0", "24.0.0"};
@@ -27,4 +46,40 @@ public String[] getUpgradableVersionRange() {
2746
public String getUpgradedVersion() {
2847
return "24.0.0";
2948
}
49+
50+
@Override
51+
public void performDataMigration(Connection conn) {
52+
restoreKmsWrappedKeysStillInUse(conn);
53+
}
54+
55+
/**
56+
* In 4.23 removing a volume also removed its KMS wrapped key, even when the key was still needed by the volume
57+
* it was migrated to or by snapshots of the volume. Restores such keys while the KEK they were wrapped with still
58+
* exists, and logs the ones that can't be recovered anymore.
59+
*/
60+
private void restoreKmsWrappedKeysStillInUse(Connection conn) {
61+
String lostKeysQuery = "SELECT w.uuid, v.uuid FROM `cloud`.`kms_wrapped_key` w"
62+
+ " JOIN `cloud`.`volumes` v ON v.kms_wrapped_key_id = w.id"
63+
+ " WHERE " + REMOVED_KMS_WRAPPED_KEY_IN_USE + " AND NOT " + KEK_VERSION_USABLE;
64+
try (PreparedStatement pstmt = conn.prepareStatement(lostKeysQuery); ResultSet rs = pstmt.executeQuery()) {
65+
while (rs.next()) {
66+
logger.warn("KMS wrapped key [{}] of volume [{}] was removed while still in use and the KEK it was "
67+
+ "wrapped with is gone; the volume and its snapshots can't be decrypted anymore",
68+
rs.getString(1), rs.getString(2));
69+
}
70+
} catch (SQLException e) {
71+
throw new CloudRuntimeException("Unable to look up KMS wrapped keys that were removed while still in use", e);
72+
}
73+
74+
String restoreQuery = "UPDATE `cloud`.`kms_wrapped_key` w SET w.removed = NULL"
75+
+ " WHERE " + REMOVED_KMS_WRAPPED_KEY_IN_USE + " AND " + KEK_VERSION_USABLE;
76+
try (PreparedStatement pstmt = conn.prepareStatement(restoreQuery)) {
77+
int restored = pstmt.executeUpdate();
78+
if (restored > 0) {
79+
logger.info("Restored {} KMS wrapped key(s) that were removed while still in use", restored);
80+
}
81+
} catch (SQLException e) {
82+
throw new CloudRuntimeException("Unable to restore KMS wrapped keys that were removed while still in use", e);
83+
}
84+
}
3085
}

‎engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ public class KMSWrappedKeyVO {
6161
@Column(name = "wrapped_blob", nullable = false)
6262
private byte[] wrappedBlob;
6363

64+
@Column(name = "wrap_algorithm", nullable = false)
65+
private String wrapAlgorithm;
66+
67+
/**
68+
* Set on every row that existed when upgrading from 4.23. Such a blob may hold a volume key that was migrated
69+
* from a legacy passphrase in the wrong format; it is checked once and the flag is cleared.
70+
*/
71+
@Column(name = "format_check_pending", nullable = false)
72+
private boolean formatCheckPending;
73+
6474
@Column(name = GenericDao.CREATED_COLUMN, nullable = false)
6575
@Temporal(TemporalType.TIMESTAMP)
6676
private Date created;
@@ -152,6 +162,22 @@ public void setWrappedBlob(byte[] wrappedBlob) {
152162
this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null;
153163
}
154164

165+
public String getWrapAlgorithm() {
166+
return wrapAlgorithm;
167+
}
168+
169+
public void setWrapAlgorithm(String wrapAlgorithm) {
170+
this.wrapAlgorithm = wrapAlgorithm;
171+
}
172+
173+
public boolean isFormatCheckPending() {
174+
return formatCheckPending;
175+
}
176+
177+
public void setFormatCheckPending(boolean formatCheckPending) {
178+
this.formatCheckPending = formatCheckPending;
179+
}
180+
155181
public Date getCreated() {
156182
return created;
157183
}
@@ -171,6 +197,6 @@ public void setRemoved(Date removed) {
171197
@Override
172198
public String toString() {
173199
return String.format("KMSWrappedKey %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(
174-
this, "id", "uuid", "kmsKeyId", "kekVersionId", "zoneId", "created", "removed"));
200+
this, "id", "uuid", "kmsKeyId", "kekVersionId", "zoneId", "wrapAlgorithm", "created", "removed"));
175201
}
176202
}

‎engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,10 @@ public interface KMSWrappedKeyDao extends GenericDao<KMSWrappedKeyVO, Long> {
3232
List<KMSWrappedKeyVO> listByKekVersionId(Long kekVersionId, int limit);
3333

3434
long countByKekVersionId(Long kekVersionId);
35+
36+
/**
37+
* Rows still flagged for the one-time format check after the upgrade from 4.23
38+
* (see {@link KMSWrappedKeyVO#isFormatCheckPending()}), with an id greater than {@code afterId}, by id.
39+
*/
40+
List<KMSWrappedKeyVO> listFormatCheckPending(long afterId, int limit);
3541
}

‎engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ public KMSWrappedKeyDaoImpl() {
3838
allFieldSearch.and("kmsKeyId", allFieldSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ);
3939
allFieldSearch.and("kekVersionId", allFieldSearch.entity().getKekVersionId(), SearchCriteria.Op.EQ);
4040
allFieldSearch.and("zoneId", allFieldSearch.entity().getZoneId(), SearchCriteria.Op.EQ);
41+
allFieldSearch.and("formatCheckPending", allFieldSearch.entity().isFormatCheckPending(), SearchCriteria.Op.EQ);
42+
allFieldSearch.and("idAfter", allFieldSearch.entity().getId(), SearchCriteria.Op.GT);
4143
allFieldSearch.done();
4244
}
4345

@@ -67,4 +69,12 @@ public long countByKekVersionId(Long kekVersionId) {
6769
Integer count = getCount(sc);
6870
return count != null ? count.longValue() : 0L;
6971
}
72+
73+
@Override
74+
public List<KMSWrappedKeyVO> listFormatCheckPending(long afterId, int limit) {
75+
SearchCriteria<KMSWrappedKeyVO> sc = allFieldSearch.create();
76+
sc.setParameters("formatCheckPending", true);
77+
sc.setParameters("idAfter", afterId);
78+
return listBy(sc, new Filter(KMSWrappedKeyVO.class, "id", true, 0L, (long) limit));
79+
}
7080
}

‎engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,36 @@ CREATE TABLE IF NOT EXISTS `cloud`.`instance_boot_group_details` (
226226
PRIMARY KEY (`id`),
227227
CONSTRAINT `fk_instance_boot_group_details__group_id` FOREIGN KEY (`boot_group_id`) REFERENCES `instance_boot_group` (`id`) ON DELETE CASCADE
228228
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
229+
230+
-- KMS: record the algorithm each key was wrapped with. Keys of the PKCS#11 provider were wrapped with
231+
-- AES/CBC/PKCS5Padding (the column default), keys of the database provider with AES/GCM/NoPadding.
232+
CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.kms_wrapped_key', 'wrap_algorithm', 'VARCHAR(64) NOT NULL DEFAULT ''AES/CBC/PKCS5Padding'' COMMENT ''Algorithm the key was wrapped with''');
233+
UPDATE `cloud`.`kms_wrapped_key` w
234+
JOIN `cloud`.`kms_kek_versions` v ON v.id = w.kek_version_id
235+
JOIN `cloud`.`kms_hsm_profiles` p ON p.id = v.hsm_profile_id
236+
SET w.wrap_algorithm = 'AES/GCM/NoPadding'
237+
WHERE LOWER(p.protocol) = 'database';
238+
239+
-- KMS: 4.23 migrated legacy volume passphrases to KMS in a wrong format. Flag every existing key for a one-time
240+
-- check by the management server, which repairs the affected ones.
241+
CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.kms_wrapped_key', 'format_check_pending', 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''1 if the key still has to be checked for the format 4.23 migrated legacy passphrases in''');
242+
UPDATE `cloud`.`kms_wrapped_key` SET format_check_pending = 1;
243+
244+
-- KMS: existing PKCS#11 HSM profiles keep wrapping keys with AES/CBC/PKCS5Padding. New profiles default to
245+
-- AES/GCM/NoPadding.
246+
INSERT INTO `cloud`.`kms_hsm_profile_details` (profile_id, name, value)
247+
SELECT p.id, 'wrap_algorithm', 'AES/CBC/PKCS5Padding' FROM `cloud`.`kms_hsm_profiles` p
248+
WHERE LOWER(p.protocol) = 'pkcs11'
249+
AND NOT EXISTS (SELECT 1 FROM `cloud`.`kms_hsm_profile_details` d WHERE d.profile_id = p.id AND d.name = 'wrap_algorithm');
250+
UPDATE `cloud`.`kms_keys` k
251+
JOIN `cloud`.`kms_hsm_profiles` p ON p.id = k.hsm_profile_id
252+
SET k.algorithm = 'AES/CBC/PKCS5Padding'
253+
WHERE LOWER(p.protocol) = 'pkcs11';
254+
255+
-- KMS: the database provider doesn't use an HSM
256+
UPDATE `cloud`.`kms_hsm_profiles` SET name = 'Database Provider' WHERE protocol = 'database' AND name = 'HSM Database Provider';
257+
258+
-- KMS: remove legacy passphrases no volume refers to anymore, e.g. those left behind when 4.23 migrated a volume to KMS
259+
DELETE p FROM `cloud`.`passphrase` p
260+
LEFT JOIN `cloud`.`volumes` v ON v.passphrase_id = p.id
261+
WHERE v.id IS NULL;

0 commit comments

Comments
 (0)