From 693fbb27f177f884d8f400668c43530aa710448c Mon Sep 17 00:00:00 2001 From: Aleksandrs Ulme Date: Tue, 25 Mar 2025 16:01:43 +0800 Subject: [PATCH 1/2] Key rotation API with retention of 5 last expired keys --- src/main/java/com/uid2/admin/Main.java | 34 ++-- .../CloudKeyRetentionStrategy.java | 9 ++ .../CloudKeyRotationStrategy.java | 73 +++++++++ .../cloudEncryption/CloudSecretGenerator.java | 17 ++ .../ExpiredKeyCountRetentionStrategy.java | 58 +++++++ .../managers/CloudEncryptionKeyManager.java | 17 +- .../java/com/uid2/admin/vertx/Endpoints.java | 1 + .../service/CloudEncryptionKeyService.java | 52 ++++++- .../ExpiredKeyCountRetentionStrategyTest.java | 81 ++++++++++ .../CloudEncryptionKeyManagerTest.java | 14 +- .../vertx/CloudEncryptionKeyServiceTest.java | 147 +++++++++++++++++- .../admin/vertx/test/ServiceTestBase.java | 11 +- 12 files changed, 470 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRetentionStrategy.java create mode 100644 src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRotationStrategy.java create mode 100644 src/main/java/com/uid2/admin/cloudEncryption/CloudSecretGenerator.java create mode 100644 src/main/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategy.java create mode 100644 src/test/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategyTest.java diff --git a/src/main/java/com/uid2/admin/Main.java b/src/main/java/com/uid2/admin/Main.java index 7b323400..7fee2588 100644 --- a/src/main/java/com/uid2/admin/Main.java +++ b/src/main/java/com/uid2/admin/Main.java @@ -5,6 +5,8 @@ import com.uid2.admin.auth.OktaAuthProvider; import com.uid2.admin.auth.AuthProvider; import com.uid2.admin.auth.TokenRefreshHandler; +import com.uid2.admin.cloudEncryption.CloudKeyRotationStrategy; +import com.uid2.admin.cloudEncryption.ExpiredKeyCountRetentionStrategy; import com.uid2.admin.job.JobDispatcher; import com.uid2.admin.job.jobsync.EncryptedFilesSyncJob; import com.uid2.admin.job.jobsync.PrivateSiteDataSyncJob; @@ -12,6 +14,7 @@ import com.uid2.admin.legacy.LegacyClientKeyStoreWriter; import com.uid2.admin.legacy.RotatingLegacyClientKeyProvider; import com.uid2.admin.managers.KeysetManager; +import com.uid2.admin.cloudEncryption.CloudSecretGenerator; import com.uid2.admin.monitoring.DataStoreMetrics; import com.uid2.admin.managers.CloudEncryptionKeyManager; import com.uid2.admin.secret.*; @@ -29,7 +32,6 @@ import com.uid2.admin.vertx.service.*; import com.uid2.shared.Const; import com.uid2.shared.Utils; -import com.uid2.shared.secret.IKeyGenerator; import com.uid2.shared.secret.KeyHasher; import com.uid2.shared.secret.SecureKeyGenerator; import com.uid2.shared.auth.EnclaveIdentifierProvider; @@ -122,7 +124,7 @@ public void run() { try { adminKeysetProvider.loadContent(); } catch (CloudStorageException e) { - if(e.getMessage().contains("The specified key does not exist")){ + if (e.getMessage().contains("The specified key does not exist")) { adminKeysetStoreWriter.upload(new HashMap<>(), null); adminKeysetProvider.loadContent(); } else { @@ -134,7 +136,7 @@ public void run() { GlobalScope keysetKeysGlobalScope = new GlobalScope(keysetKeyMetadataPath); RotatingKeysetKeyStore keysetKeysProvider = new RotatingKeysetKeyStore(cloudStorage, keysetKeysGlobalScope); KeysetKeyStoreWriter keysetKeyStoreWriter = new KeysetKeyStoreWriter(keysetKeysProvider, fileManager, versionGenerator, clock, keysetKeysGlobalScope, enableKeysets); - if(enableKeysets) { + if (enableKeysets) { try { keysetKeysProvider.loadContent(); } catch (CloudStorageException e) { @@ -154,7 +156,7 @@ public void run() { try { clientSideKeypairProvider.loadContent(); } catch (CloudStorageException e) { - if(e.getMessage().contains("The specified key does not exist")) { + if (e.getMessage().contains("The specified key does not exist")) { clientSideKeypairStoreWriter.upload(new HashSet<>(), null); clientSideKeypairProvider.loadContent(); } else { @@ -163,13 +165,13 @@ public void run() { } CloudPath serviceMetadataPath = new CloudPath(config.getString(Const.Config.ServiceMetadataPathProp)); - GlobalScope serviceGlobalScope= new GlobalScope(serviceMetadataPath); + GlobalScope serviceGlobalScope = new GlobalScope(serviceMetadataPath); RotatingServiceStore serviceProvider = new RotatingServiceStore(cloudStorage, serviceGlobalScope); ServiceStoreWriter serviceStoreWriter = new ServiceStoreWriter(serviceProvider, fileManager, jsonWriter, versionGenerator, clock, serviceGlobalScope); try { serviceProvider.loadContent(); } catch (CloudStorageException e) { - if(e.getMessage().contains("The specified key does not exist")) { + if (e.getMessage().contains("The specified key does not exist")) { serviceStoreWriter.upload(new HashSet<>(), null); serviceProvider.loadContent(); } else { @@ -178,13 +180,13 @@ public void run() { } CloudPath serviceLinkMetadataPath = new CloudPath(config.getString(Const.Config.ServiceLinkMetadataPathProp)); - GlobalScope serviceLinkGlobalScope= new GlobalScope(serviceLinkMetadataPath); + GlobalScope serviceLinkGlobalScope = new GlobalScope(serviceLinkMetadataPath); RotatingServiceLinkStore serviceLinkProvider = new RotatingServiceLinkStore(cloudStorage, serviceLinkGlobalScope); ServiceLinkStoreWriter serviceLinkStoreWriter = new ServiceLinkStoreWriter(serviceLinkProvider, fileManager, jsonWriter, versionGenerator, clock, serviceLinkGlobalScope); try { serviceLinkProvider.loadContent(); } catch (CloudStorageException e) { - if(e.getMessage().contains("The specified key does not exist")) { + if (e.getMessage().contains("The specified key does not exist")) { serviceLinkStoreWriter.upload(new HashSet<>(), null); serviceLinkProvider.loadContent(); } else { @@ -202,8 +204,7 @@ public void run() { GlobalScope cloudEncryptionKeyGlobalScope = new GlobalScope(cloudEncryptionKeyMetadataPath); RotatingCloudEncryptionKeyProvider rotatingCloudEncryptionKeyProvider = new RotatingCloudEncryptionKeyProvider(cloudStorage, cloudEncryptionKeyGlobalScope); CloudEncryptionKeyStoreWriter cloudEncryptionKeyStoreWriter = new CloudEncryptionKeyStoreWriter(rotatingCloudEncryptionKeyProvider, fileManager, jsonWriter, versionGenerator, clock, cloudEncryptionKeyGlobalScope); - IKeyGenerator keyGenerator = new SecureKeyGenerator(); - CloudEncryptionKeyManager cloudEncryptionKeyManager = new CloudEncryptionKeyManager(rotatingCloudEncryptionKeyProvider, cloudEncryptionKeyStoreWriter,keyGenerator); + SecureKeyGenerator keyGenerator = new SecureKeyGenerator(); try { rotatingCloudEncryptionKeyProvider.loadContent(); } catch (CloudStorageException e) { @@ -247,6 +248,11 @@ public void run() { ClientSideKeypairService clientSideKeypairService = new ClientSideKeypairService(config, auth, writeLock, clientSideKeypairStoreWriter, clientSideKeypairProvider, siteProvider, keysetManager, keypairGenerator, clock); + var cloudEncryptionSecretGenerator = new CloudSecretGenerator(keyGenerator); + var cloudEncryptionKeyManager = new CloudEncryptionKeyManager(rotatingCloudEncryptionKeyProvider, cloudEncryptionKeyStoreWriter, cloudEncryptionSecretGenerator); + var cloudEncryptionKeyRetentionStrategy = new ExpiredKeyCountRetentionStrategy(clock, 5); + var cloudEncryptionKeyRotationStrategy = new CloudKeyRotationStrategy(cloudEncryptionSecretGenerator, clock, cloudEncryptionKeyRetentionStrategy); + IService[] services = { new ClientKeyService(config, auth, writeLock, clientKeyStoreWriter, clientKeyProvider, siteProvider, keysetManager, keyGenerator, keyHasher), new EnclaveIdService(auth, writeLock, enclaveStoreWriter, enclaveIdProvider, clock), @@ -263,7 +269,7 @@ public void run() { new PrivateSiteDataRefreshService(auth, jobDispatcher, writeLock, config, rotatingCloudEncryptionKeyProvider), new JobDispatcherService(auth, jobDispatcher), new SearchService(auth, clientKeyProvider, operatorKeyProvider), - new CloudEncryptionKeyService(auth, rotatingCloudEncryptionKeyProvider) + new CloudEncryptionKeyService(auth, rotatingCloudEncryptionKeyProvider, cloudEncryptionKeyStoreWriter, siteProvider, cloudEncryptionKeyRotationStrategy) }; @@ -279,7 +285,7 @@ public void run() { try { keysetProvider.loadContent(); } catch (CloudStorageException e) { - if(e.getMessage().contains("The specified key does not exist")){ + if (e.getMessage().contains("The specified key does not exist")) { keysetStoreWriter.upload(new HashMap<>(), null); keysetProvider.loadContent(); } else { @@ -305,7 +311,7 @@ public void run() { The jobs are executed after because they copy data from these files locations consumed by public and private operators. This caused an issue because the files were empty and the job started to fail so the operators got empty files. */ - if(enableKeysets) { + if (enableKeysets) { synchronized (writeLock) { //UID2-628 keep keys.json and keyset_keys.json in sync. This function syncs them on start up keysetProvider.loadContent(); @@ -342,7 +348,7 @@ public void run() { CompletableFuture privateSiteDataSyncJobFuture = jobDispatcher.executeNextJob(); privateSiteDataSyncJobFuture.get(); - EncryptedFilesSyncJob encryptedFilesSyncJob = new EncryptedFilesSyncJob(config, writeLock,rotatingCloudEncryptionKeyProvider); + EncryptedFilesSyncJob encryptedFilesSyncJob = new EncryptedFilesSyncJob(config, writeLock, rotatingCloudEncryptionKeyProvider); jobDispatcher.enqueue(encryptedFilesSyncJob); CompletableFuture encryptedFilesSyncJobFuture = jobDispatcher.executeNextJob(); encryptedFilesSyncJobFuture.get(); diff --git a/src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRetentionStrategy.java b/src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRetentionStrategy.java new file mode 100644 index 00000000..fc7926e3 --- /dev/null +++ b/src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRetentionStrategy.java @@ -0,0 +1,9 @@ +package com.uid2.admin.cloudEncryption; + +import com.uid2.shared.model.CloudEncryptionKey; + +import java.util.Set; + +public interface CloudKeyRetentionStrategy { + Set selectKeysToRetain(Set keysForSite); +} diff --git a/src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRotationStrategy.java b/src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRotationStrategy.java new file mode 100644 index 00000000..bcc6d198 --- /dev/null +++ b/src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRotationStrategy.java @@ -0,0 +1,73 @@ +package com.uid2.admin.cloudEncryption; + +import com.google.common.collect.Streams; +import com.uid2.admin.store.Clock; +import com.uid2.shared.model.CloudEncryptionKey; +import com.uid2.shared.model.Site; + +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class CloudKeyRotationStrategy { + private final CloudSecretGenerator secretGenerator; + private final Clock clock; + private final CloudKeyRetentionStrategy keyRetentionStrategy; + + public CloudKeyRotationStrategy( + CloudSecretGenerator secretGenerator, + Clock clock, + CloudKeyRetentionStrategy keyRetentionStrategy + ) { + this.secretGenerator = secretGenerator; + this.clock = clock; + this.keyRetentionStrategy = keyRetentionStrategy; + } + + public Set computeDesiredKeys( + Collection existingKeys, + Collection sites + ) { + var idGenerator = new KeyIdGenerator(existingKeys); + Map> existingKeysBySite = existingKeys + .stream() + .collect(Collectors.groupingBy(CloudEncryptionKey::getSiteId, Collectors.toSet())); + + return sites + .stream() + .map(Site::getId) + .distinct() + .flatMap(siteId -> desiredKeysForSite(siteId, idGenerator, existingKeysBySite.getOrDefault(siteId, Set.of()))) + .collect(Collectors.toSet()); + } + + private Stream desiredKeysForSite( + Integer siteId, + KeyIdGenerator idGenerator, + Set existingKeys + ) { + var withNewKey = Streams.concat(existingKeys.stream(), Stream.of(makeNewKey(siteId, idGenerator))); + return keyRetentionStrategy.selectKeysToRetain(withNewKey.collect(Collectors.toSet())).stream(); + } + + private CloudEncryptionKey makeNewKey(Integer siteId, KeyIdGenerator idGenerator) { + var nowSeconds = clock.getEpochSecond(); + var keyId = idGenerator.nextId(); + var secret = secretGenerator.generate(); + return new CloudEncryptionKey(keyId, siteId, nowSeconds, nowSeconds, secret); + } + + private static class KeyIdGenerator { + private int lastId; + + public KeyIdGenerator(Collection existingKeys) { + this.lastId = existingKeys.stream().map(CloudEncryptionKey::getId).max(Integer::compareTo).orElse(0); + } + + public int nextId() { + return ++lastId; + } + } +} diff --git a/src/main/java/com/uid2/admin/cloudEncryption/CloudSecretGenerator.java b/src/main/java/com/uid2/admin/cloudEncryption/CloudSecretGenerator.java new file mode 100644 index 00000000..5dc7eaa5 --- /dev/null +++ b/src/main/java/com/uid2/admin/cloudEncryption/CloudSecretGenerator.java @@ -0,0 +1,17 @@ +package com.uid2.admin.cloudEncryption; + +import com.uid2.shared.secret.SecureKeyGenerator; + +public class CloudSecretGenerator { + private final SecureKeyGenerator keyGenerator; + + // The SecureKeyGenerator is preferable to the IKeyGenerator interface as it doesn't throw Exception + public CloudSecretGenerator(SecureKeyGenerator keyGenerator) { + this.keyGenerator = keyGenerator; + } + + public String generate() { + //Generate a 32-byte key for AesGcm + return keyGenerator.generateRandomKeyString(32); + } +} diff --git a/src/main/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategy.java b/src/main/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategy.java new file mode 100644 index 00000000..10c04b6f --- /dev/null +++ b/src/main/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategy.java @@ -0,0 +1,58 @@ +package com.uid2.admin.cloudEncryption; + +import com.uid2.admin.store.Clock; +import com.uid2.shared.model.CloudEncryptionKey; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +// Keep up to `expiredKeysToRetain` most recent expired keys +// Considers all keys passed, doesn't do grouping by site (handled upstream) +public class ExpiredKeyCountRetentionStrategy implements CloudKeyRetentionStrategy { + private final Clock clock; + private final int expiredKeysToRetain; + + public ExpiredKeyCountRetentionStrategy(Clock clock, int expiredKeysToRetain) { + this.clock = clock; + this.expiredKeysToRetain = expiredKeysToRetain; + } + + @Override + public Set selectKeysToRetain(Set keysForSite) { + var activeKey = findActiveKey(keysForSite); + if (activeKey == null) { + return keysForSite; + } + + var expiredKeys = new ArrayList(); + var nonExpiredKeys = new ArrayList(); + for (var key : keysForSite) { + if (key.getActivates() < activeKey.getActivates()) { + expiredKeys.add(key); + } else { + nonExpiredKeys.add(key); + } + } + + var retainedExpiredKeys = pickRetainedExpiredKeys(expiredKeys); + return Stream.concat(retainedExpiredKeys, nonExpiredKeys.stream()).collect(Collectors.toSet()); + + } + + private Stream pickRetainedExpiredKeys(ArrayList expiredKeys) { + return expiredKeys + .stream() + .sorted(Comparator.comparingLong(CloudEncryptionKey::getActivates).reversed()) + .limit(expiredKeysToRetain); + } + + private CloudEncryptionKey findActiveKey(Set keys) { + var keysPastActivation = keys.stream().filter(key -> key.getActivates() <= clock.getEpochSecond()); + return keysPastActivation + .max(Comparator.comparingLong(CloudEncryptionKey::getActivates)) + .orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/com/uid2/admin/managers/CloudEncryptionKeyManager.java b/src/main/java/com/uid2/admin/managers/CloudEncryptionKeyManager.java index a37ea01a..76805b18 100644 --- a/src/main/java/com/uid2/admin/managers/CloudEncryptionKeyManager.java +++ b/src/main/java/com/uid2/admin/managers/CloudEncryptionKeyManager.java @@ -1,9 +1,9 @@ package com.uid2.admin.managers; +import com.uid2.admin.cloudEncryption.CloudSecretGenerator; import com.uid2.admin.store.writer.CloudEncryptionKeyStoreWriter; import com.uid2.shared.auth.OperatorKey; import com.uid2.shared.model.CloudEncryptionKey; -import com.uid2.shared.secret.IKeyGenerator; import com.uid2.shared.store.reader.RotatingCloudEncryptionKeyProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,16 +21,16 @@ public class CloudEncryptionKeyManager { private final RotatingCloudEncryptionKeyProvider RotatingCloudEncryptionKeyProvider; private final CloudEncryptionKeyStoreWriter cloudEncryptionKeyStoreWriter; - private final IKeyGenerator keyGenerator; + private final CloudSecretGenerator secretGenerator; public CloudEncryptionKeyManager( RotatingCloudEncryptionKeyProvider RotatingCloudEncryptionKeyProvider, CloudEncryptionKeyStoreWriter cloudEncryptionKeyStoreWriter, - IKeyGenerator keyGenerator + CloudSecretGenerator keyGenerator ) { this.RotatingCloudEncryptionKeyProvider = RotatingCloudEncryptionKeyProvider; this.cloudEncryptionKeyStoreWriter = cloudEncryptionKeyStoreWriter; - this.keyGenerator = keyGenerator; + this.secretGenerator = keyGenerator; } // Ensures there are `keyCountPerSite` sites for each site corresponding of operatorKeys. If there are less - create new ones. @@ -89,15 +89,10 @@ private static Set uniqueSiteIdsForOperators(Collection op CloudEncryptionKey generateCloudEncryptionKey(int siteId, long activates, long created) throws Exception { int newKeyId = getNextKeyId(); - String secret = generateSecret(); + String secret = secretGenerator.generate(); return new CloudEncryptionKey(newKeyId, siteId, activates, created, secret); } - String generateSecret() throws Exception { - //Generate a 32-byte key for AesGcm - return keyGenerator.generateRandomKeyString(32); - } - void addCloudEncryptionKey(CloudEncryptionKey cloudEncryptionKey) throws Exception { Map cloudEncryptionKeys = new HashMap<>(RotatingCloudEncryptionKeyProvider.getAll()); cloudEncryptionKeys.put(cloudEncryptionKey.getId(), cloudEncryptionKey); @@ -117,7 +112,7 @@ int getNextKeyId() { CloudEncryptionKey createAndAddImmediateCloudEncryptionKey(int siteId) throws Exception { int newKeyId = getNextKeyId(); long created = Instant.now().getEpochSecond(); - CloudEncryptionKey newKey = new CloudEncryptionKey(newKeyId, siteId, created, created, generateSecret()); + CloudEncryptionKey newKey = new CloudEncryptionKey(newKeyId, siteId, created, created, secretGenerator.generate()); addCloudEncryptionKey(newKey); return newKey; } diff --git a/src/main/java/com/uid2/admin/vertx/Endpoints.java b/src/main/java/com/uid2/admin/vertx/Endpoints.java index ddbe1400..4464d903 100644 --- a/src/main/java/com/uid2/admin/vertx/Endpoints.java +++ b/src/main/java/com/uid2/admin/vertx/Endpoints.java @@ -99,6 +99,7 @@ public enum Endpoints { API_SITE_UPDATE("/api/site/update"), CLOUD_ENCRYPTION_KEY_LIST("/api/cloud-encryption-key/list"), + CLOUD_ENCRYPTION_KEY_ROTATE("/api/cloud-encryption-key/rotate"), LOGIN("/login"), LOGOUT("/logout"), diff --git a/src/main/java/com/uid2/admin/vertx/service/CloudEncryptionKeyService.java b/src/main/java/com/uid2/admin/vertx/service/CloudEncryptionKeyService.java index de21b7d3..fb89c211 100644 --- a/src/main/java/com/uid2/admin/vertx/service/CloudEncryptionKeyService.java +++ b/src/main/java/com/uid2/admin/vertx/service/CloudEncryptionKeyService.java @@ -3,28 +3,44 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.uid2.admin.auth.AdminAuthMiddleware; +import com.uid2.admin.cloudEncryption.CloudKeyRotationStrategy; import com.uid2.admin.model.CloudEncryptionKeyListResponse; import com.uid2.admin.model.CloudEncryptionKeySummary; +import com.uid2.admin.store.writer.CloudEncryptionKeyStoreWriter; import com.uid2.admin.vertx.Endpoints; import com.uid2.shared.auth.Role; +import com.uid2.shared.model.CloudEncryptionKey; import com.uid2.shared.store.reader.RotatingCloudEncryptionKeyProvider; +import com.uid2.shared.store.reader.RotatingSiteStore; import com.uid2.shared.util.Mapper; import io.vertx.core.http.HttpHeaders; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; -import java.util.List; +import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; -import java.util.stream.Stream; public class CloudEncryptionKeyService implements IService { private final AdminAuthMiddleware auth; private final RotatingCloudEncryptionKeyProvider keyProvider; + private final RotatingSiteStore siteProvider; + private final CloudEncryptionKeyStoreWriter keyWriter; + private final CloudKeyRotationStrategy rotationStrategy; private static final ObjectMapper OBJECT_MAPPER = Mapper.getInstance(); - public CloudEncryptionKeyService(AdminAuthMiddleware auth, RotatingCloudEncryptionKeyProvider keyProvider) { + public CloudEncryptionKeyService( + AdminAuthMiddleware auth, + RotatingCloudEncryptionKeyProvider keyProvider, + CloudEncryptionKeyStoreWriter keyWriter, + RotatingSiteStore siteProvider, + CloudKeyRotationStrategy rotationStrategy + ) { this.auth = auth; this.keyProvider = keyProvider; + this.siteProvider = siteProvider; + this.keyWriter = keyWriter; + this.rotationStrategy = rotationStrategy; } @Override @@ -32,10 +48,40 @@ public void setupRoutes(Router router) { router.get(Endpoints.CLOUD_ENCRYPTION_KEY_LIST.toString()).handler( auth.handle(this::handleList, Role.MAINTAINER) ); + + router.post(Endpoints.CLOUD_ENCRYPTION_KEY_ROTATE.toString()).handler( + auth.handle(this::handleRotate, Role.MAINTAINER) + ); + } + + private void handleRotate(RoutingContext rc) { + try { + keyProvider.loadContent(); + siteProvider.loadContent(); + var allSites = siteProvider.getAllSites(); + var existingKeys = keyProvider.getAll().values(); + + var desiredKeys = rotationStrategy.computeDesiredKeys(existingKeys, allSites); + writeKeys(desiredKeys); + + rc.response().end(); + } catch (Exception e) { + rc.fail(500, e); + } + } + + private void writeKeys(Set desiredKeys) throws Exception { + var keysForWriting = desiredKeys.stream().collect(Collectors.toMap( + CloudEncryptionKey::getId, + Function.identity()) + ); + keyWriter.upload(keysForWriting, null); } private void handleList(RoutingContext rc) { try { + keyProvider.loadContent(); + var keySummaries = keyProvider.getAll() .values() .stream() diff --git a/src/test/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategyTest.java b/src/test/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategyTest.java new file mode 100644 index 00000000..26a6f980 --- /dev/null +++ b/src/test/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategyTest.java @@ -0,0 +1,81 @@ +package com.uid2.admin.cloudEncryption; + +import com.uid2.admin.store.Clock; +import com.uid2.shared.model.CloudEncryptionKey; +import org.assertj.core.api.AssertionsForClassTypes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ExpiredKeyCountRetentionStrategyTest { + private final long past1 = 100L; + private final long past2 = 200L; + private final long past3 = 300L; + private final long now = 400L; + private final long future = 500L; + private ExpiredKeyCountRetentionStrategy strategy; + private Clock clock; + + @BeforeEach + void setUp() { + clock = mock(Clock.class); + strategy = new ExpiredKeyCountRetentionStrategy(clock, 2); + when(clock.getEpochSecond()).thenReturn(now); + } + + @Test + void selectKeysToRetain_withNoKeys() { + var originalKeys = new HashSet(); + + var actual = strategy.selectKeysToRetain(originalKeys); + + assertThat(actual).isEqualTo(originalKeys); + } + + @Test + void selectKeysToRetain_withLessThanNKeys() { + var originalKeys = Set.of( + new CloudEncryptionKey(1, 1, past1, past1, "secret 1") + ); + + var actual = strategy.selectKeysToRetain(originalKeys); + + AssertionsForClassTypes.assertThat(actual).isEqualTo(originalKeys); + } + + @Test + void selectKeysToRetain_withMoreThanNNonExpiredKeys() { + var activeKey = new CloudEncryptionKey(1, 1, now, now, "secret 1"); + var futureKey1 = new CloudEncryptionKey(2, 1, future, now, "secret 2"); + var futureKey2 = new CloudEncryptionKey(3, 1, future, now, "secret 3"); + var futureKey3 = new CloudEncryptionKey(4, 1, future, now, "secret 4"); + var expiredKey1 = new CloudEncryptionKey(5, 1, past1, now, "secret 5"); + var originalKeys = Set.of(activeKey, futureKey1, futureKey2, futureKey3, expiredKey1); + + var actual = strategy.selectKeysToRetain(originalKeys); + + assertThat(actual).isEqualTo(originalKeys); + } + + @Test + void selectKeysToRetain_withMoreThanNExpiredKeys() { + var oldestExpiredKey = new CloudEncryptionKey(1, 1, past1, past1, "secret 1"); // Don't retain + var expiredKey2 = new CloudEncryptionKey(2, 1, past2, past1, "secret 2"); + var expiredKey3 = new CloudEncryptionKey(3, 1, past3, past1, "secret 3"); + var activeKey = new CloudEncryptionKey(3, 1, now, past1, "secret 4"); + var futureKey = new CloudEncryptionKey(3, 1, now, past1, "secret 5"); + var originalKeys = Set.of(oldestExpiredKey, expiredKey2, expiredKey3, activeKey, futureKey); + + var expected = Set.of(expiredKey2, expiredKey3, activeKey, futureKey); + + var actual = strategy.selectKeysToRetain(originalKeys); + + assertThat(actual).isEqualTo(expected); + } +} \ No newline at end of file diff --git a/src/test/java/com/uid2/admin/managers/CloudEncryptionKeyManagerTest.java b/src/test/java/com/uid2/admin/managers/CloudEncryptionKeyManagerTest.java index 8c687612..41837816 100644 --- a/src/test/java/com/uid2/admin/managers/CloudEncryptionKeyManagerTest.java +++ b/src/test/java/com/uid2/admin/managers/CloudEncryptionKeyManagerTest.java @@ -1,9 +1,9 @@ package com.uid2.admin.managers; +import com.uid2.admin.cloudEncryption.CloudSecretGenerator; import com.uid2.admin.store.writer.CloudEncryptionKeyStoreWriter; import com.uid2.shared.auth.OperatorKey; import com.uid2.shared.model.CloudEncryptionKey; -import com.uid2.shared.secret.IKeyGenerator; import com.uid2.shared.store.reader.RotatingCloudEncryptionKeyProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,7 +18,7 @@ class CloudEncryptionKeyManagerTest { private RotatingCloudEncryptionKeyProvider cloudEncryptionKeyProvider; private CloudEncryptionKeyStoreWriter cloudEncryptionKeyStoreWriter; - private IKeyGenerator keyGenerator; + private CloudSecretGenerator keyGenerator; private CloudEncryptionKeyManager cloudEncryptionKeyManager; private final long keyActivateInterval = 3600; // 1 hour @@ -29,13 +29,13 @@ class CloudEncryptionKeyManagerTest { void setUp() { cloudEncryptionKeyProvider = mock(RotatingCloudEncryptionKeyProvider.class); cloudEncryptionKeyStoreWriter = mock(CloudEncryptionKeyStoreWriter.class); - keyGenerator = mock(IKeyGenerator.class); + keyGenerator = mock(CloudSecretGenerator.class); cloudEncryptionKeyManager = new CloudEncryptionKeyManager(cloudEncryptionKeyProvider, cloudEncryptionKeyStoreWriter, keyGenerator); } @Test void testGenerateCloudEncryptionKey() throws Exception { - when(keyGenerator.generateRandomKeyString(32)).thenReturn("randomKeyString"); + when(keyGenerator.generate()).thenReturn("randomKeyString"); CloudEncryptionKey cloudEncryptionKey = cloudEncryptionKeyManager.generateCloudEncryptionKey(siteId, 1000L, 2000L); @@ -179,7 +179,7 @@ void testGetAllCloudEncryptionKeysBySiteId() { @Test void testCreateAndAddImmediateCloudEncryptionKey() throws Exception { when(cloudEncryptionKeyProvider.getAll()).thenReturn(new HashMap<>()); - when(keyGenerator.generateRandomKeyString(32)).thenReturn("generatedSecret"); + when(keyGenerator.generate()).thenReturn("generatedSecret"); CloudEncryptionKey newKey = cloudEncryptionKeyManager.createAndAddImmediateCloudEncryptionKey(100); @@ -303,7 +303,7 @@ void testGenerateKeysForOperators() throws Exception { existingKeys.put(1, new CloudEncryptionKey(1, 100, 1000L, 900L, "existingKey1")); when(cloudEncryptionKeyProvider.getAll()).thenReturn(existingKeys); - when(keyGenerator.generateRandomKeyString(32)).thenReturn("generatedSecret"); + when(keyGenerator.generate()).thenReturn("generatedSecret"); cloudEncryptionKeyManager.generateKeysForOperators(operatorKeys, keyActivateInterval, keyCountPerSite); @@ -378,7 +378,7 @@ void testGenerateKeysForOperators_MultipleSitesWithVaryingExistingKeys() throws existingKeys.put(3, new CloudEncryptionKey(3, 200, 3000L, 2900L, "existingKey3")); when(cloudEncryptionKeyProvider.getAll()).thenReturn(existingKeys); - when(keyGenerator.generateRandomKeyString(32)).thenReturn("generatedSecret"); + when(keyGenerator.generate()).thenReturn("generatedSecret"); cloudEncryptionKeyManager.generateKeysForOperators(operatorKeys, keyActivateInterval, keyCountPerSite); diff --git a/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java b/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java index 0adb109e..1e8935ac 100644 --- a/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java @@ -1,7 +1,10 @@ package com.uid2.admin.vertx; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.uid2.admin.cloudEncryption.CloudKeyRotationStrategy; +import com.uid2.admin.cloudEncryption.ExpiredKeyCountRetentionStrategy; import com.uid2.admin.model.CloudEncryptionKeyListResponse; import com.uid2.admin.model.CloudEncryptionKeySummary; import com.uid2.admin.vertx.service.CloudEncryptionKeyService; @@ -9,33 +12,60 @@ import com.uid2.admin.vertx.test.ServiceTestBase; import com.uid2.shared.auth.Role; import com.uid2.shared.model.CloudEncryptionKey; +import com.uid2.shared.model.Site; import com.uid2.shared.util.Mapper; import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.web.client.HttpResponse; import io.vertx.junit5.VertxTestContext; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class CloudEncryptionKeyServiceTest extends ServiceTestBase { private static final ObjectMapper OBJECT_MAPPER = Mapper.getInstance(); + private final CloudEncryptionKeyListResponse noKeys = new CloudEncryptionKeyListResponse(List.of()); + private final long longAgo = 0L; + private final long before = 100L; + private final long now = 200L; + private final int siteId1 = 1; + private final int keyId1 = 1; + private final int keyId2 = 2; + private final int keyId3 = 3; + private final int keyId4 = 4; + private final String siteName1 = "Site 1"; + private final Site site1 = new Site(siteId1, siteName1, true); + private final String secret1 = "secret 1"; + private final String secret2 = "secret 2"; + private final String secret3 = "secret 3"; + private final String secret4 = "secret4"; @Override protected IService createService() { - return new CloudEncryptionKeyService(auth, cloudEncryptionKeyProvider); + var retentionStrategy = new ExpiredKeyCountRetentionStrategy(clock, 2); + var rotationStrategy = new CloudKeyRotationStrategy(cloudSecretGenerator, clock, retentionStrategy); + + return new CloudEncryptionKeyService( + auth, + cloudEncryptionKeyProvider, + cloudEncryptionKeyStoreWriter, + siteProvider, + rotationStrategy + ); } @Test public void testList_noKeys(Vertx vertx, VertxTestContext testContext) { fakeAuth(Role.MAINTAINER); - var expected = new CloudEncryptionKeyListResponse(List.of()); get(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_LIST, response -> { assertEquals(200, response.statusCode()); - - CloudEncryptionKeyListResponse actual = OBJECT_MAPPER.readValue(response.bodyAsString(), new TypeReference<>() {}); - assertEquals(expected, actual); + assertEquals(noKeys, parseKeyListResponse(response)); testContext.completeNow(); }); @@ -45,6 +75,7 @@ public void testList_noKeys(Vertx vertx, VertxTestContext testContext) { public void testList_noAccess(Vertx vertx, VertxTestContext testContext) { get(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_LIST, response -> { assertEquals(401, response.statusCode()); + testContext.completeNow(); }); } @@ -65,11 +96,113 @@ public void testList_withKeys(Vertx vertx, VertxTestContext testContext) { get(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_LIST, response -> { assertEquals(200, response.statusCode()); + assertEquals(expected, parseKeyListResponse(response)); + + testContext.completeNow(); + }); + } + + @Test + public void testRotate_noAccess(Vertx vertx, VertxTestContext testContext) { + post(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_ROTATE, null, response -> { + assertEquals(401, response.statusCode()); + + testContext.completeNow(); + }); + } + + @Test + public void testRotate_noSitesDoesNothing(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + setCloudEncryptionKeys(); + setSites(); + + post(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_ROTATE, null, rotateResponse -> { + assertEquals(200, rotateResponse.statusCode()); + + get(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_LIST, listResponse -> { + assertEquals(200, listResponse.statusCode()); + assertEquals(noKeys, parseKeyListResponse(listResponse)); + + testContext.completeNow(); + }); + }); + } + + @Test + public void testRotate_forSiteWithNoKeysCreatesKey(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + setCloudEncryptionKeys(); + setSites(site1); + when(cloudSecretGenerator.generate()).thenReturn(secret1); + when(clock.getEpochSecond()).thenReturn(now); + + var expected = Map.of( + siteId1, new CloudEncryptionKey(keyId1, siteId1, now, now, secret1) + ); + + post(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_ROTATE, null, rotateResponse -> { + assertEquals(200, rotateResponse.statusCode()); + verify(cloudEncryptionKeyStoreWriter).upload(expected, null); + testContext.completeNow(); + }); + } - CloudEncryptionKeyListResponse actual = OBJECT_MAPPER.readValue(response.bodyAsString(), new TypeReference<>() {}); - assertEquals(expected, actual); + @Test + public void testRotate_forSiteWithKeyCreatesNewActiveKey(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + var existingKey1 = new CloudEncryptionKey(keyId1, siteId1, before, before, secret1); + var existingKey2 = new CloudEncryptionKey(keyId2, siteId1, longAgo, longAgo, secret2); + var existingKey3 = new CloudEncryptionKey(keyId3, siteId1, before, before, secret3); + + var expected = Map.of( + keyId1, existingKey1, + // We allow 2 expired keys, but have 4. Key 2 is removed as oldest expired key. + keyId3, existingKey3, + keyId4, new CloudEncryptionKey(4, siteId1, now, now, secret4) + ); + + setCloudEncryptionKeys(existingKey1, existingKey2, existingKey3); + setSites(site1); + when(cloudSecretGenerator.generate()).thenReturn(secret4); + when(clock.getEpochSecond()).thenReturn(now); + + post(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_ROTATE, null, rotateResponse -> { + assertEquals(200, rotateResponse.statusCode()); + verify(cloudEncryptionKeyStoreWriter).upload(expected, null); testContext.completeNow(); }); } + + @Test + public void testRotate_removesExcessiveExpiredKeys(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + + var existingKey = new CloudEncryptionKey(siteId1, siteId1, before, before, secret1); + var key2Id = keyId1 + 1; + + var expected = Map.of( + keyId1, existingKey, + key2Id, new CloudEncryptionKey(key2Id, siteId1, now, now, secret2) + ); + + setCloudEncryptionKeys(existingKey); + setSites(site1); + when(cloudSecretGenerator.generate()).thenReturn(secret2); + when(clock.getEpochSecond()).thenReturn(now); + + post(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_ROTATE, null, rotateResponse -> { + assertEquals(200, rotateResponse.statusCode()); + verify(cloudEncryptionKeyStoreWriter).upload(expected, null); + testContext.completeNow(); + }); + } + + private static CloudEncryptionKeyListResponse parseKeyListResponse(HttpResponse response) throws JsonProcessingException { + return OBJECT_MAPPER.readValue(response.bodyAsString(), new TypeReference<>() { + }); + } } diff --git a/src/test/java/com/uid2/admin/vertx/test/ServiceTestBase.java b/src/test/java/com/uid2/admin/vertx/test/ServiceTestBase.java index fc9b2e9c..7bbb315c 100644 --- a/src/test/java/com/uid2/admin/vertx/test/ServiceTestBase.java +++ b/src/test/java/com/uid2/admin/vertx/test/ServiceTestBase.java @@ -9,6 +9,7 @@ import com.uid2.admin.legacy.LegacyClientKeyStoreWriter; import com.uid2.admin.legacy.RotatingLegacyClientKeyProvider; import com.uid2.admin.managers.KeysetManager; +import com.uid2.admin.cloudEncryption.CloudSecretGenerator; import com.uid2.admin.secret.IEncryptionKeyManager; import com.uid2.admin.vertx.Endpoints; import com.uid2.shared.model.*; @@ -39,7 +40,6 @@ import io.vertx.ext.web.handler.AuthenticationHandler; import io.vertx.junit5.VertxExtension; import io.vertx.junit5.VertxTestContext; -import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; @@ -101,7 +101,10 @@ public abstract class ServiceTestBase { protected SaltStoreWriter saltStoreWriter; @Mock protected PartnerStoreWriter partnerStoreWriter; - + @Mock + protected CloudEncryptionKeyStoreWriter cloudEncryptionKeyStoreWriter; + @Mock + protected CloudSecretGenerator cloudSecretGenerator; @Mock protected IEncryptionKeyManager keyManager; @Mock @@ -232,6 +235,10 @@ protected void post(Vertx vertx, VertxTestContext testContext, String endpoint, client.postAbs(getUrlForEndpoint(endpoint)).sendBuffer(body != null ? Buffer.buffer(body) : null).onComplete(testContext.succeeding(response -> testContext.verify(() -> handler.handle(response)))); } + protected void post(Vertx vertx, VertxTestContext testContext, Endpoints endpoint, String body, TestHandler> handler) { + post(vertx, testContext, endpoint.toString(), body, handler); + } + protected void postWithoutBody(Vertx vertx, VertxTestContext testContext, String endpoint, TestHandler> handler) { post(vertx, testContext, endpoint, null, handler); } From d657f5c4fd2df63a88078c7b7a9742e403208fd7 Mon Sep 17 00:00:00 2001 From: Aleksandrs Ulme Date: Wed, 26 Mar 2025 13:09:20 +0800 Subject: [PATCH 2/2] Address feedback --- src/main/java/com/uid2/admin/Main.java | 1 - .../CloudKeyRotationStrategy.java | 5 ++- .../ExpiredKeyCountRetentionStrategy.java | 40 +++---------------- .../service/CloudEncryptionKeyService.java | 2 +- .../ExpiredKeyCountRetentionStrategyTest.java | 30 ++++++-------- .../vertx/CloudEncryptionKeyServiceTest.java | 10 +++++ 6 files changed, 33 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/uid2/admin/Main.java b/src/main/java/com/uid2/admin/Main.java index 7fee2588..d9748eed 100644 --- a/src/main/java/com/uid2/admin/Main.java +++ b/src/main/java/com/uid2/admin/Main.java @@ -76,7 +76,6 @@ public class Main { private final Vertx vertx; private final JsonObject config; - public Main(Vertx vertx, JsonObject config) { this.vertx = vertx; this.config = config; diff --git a/src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRotationStrategy.java b/src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRotationStrategy.java index bcc6d198..1cdb3a38 100644 --- a/src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRotationStrategy.java +++ b/src/main/java/com/uid2/admin/cloudEncryption/CloudKeyRotationStrategy.java @@ -48,8 +48,9 @@ private Stream desiredKeysForSite( KeyIdGenerator idGenerator, Set existingKeys ) { - var withNewKey = Streams.concat(existingKeys.stream(), Stream.of(makeNewKey(siteId, idGenerator))); - return keyRetentionStrategy.selectKeysToRetain(withNewKey.collect(Collectors.toSet())).stream(); + var existingKeysToRetain = keyRetentionStrategy.selectKeysToRetain(existingKeys); + var newKey = makeNewKey(siteId, idGenerator); + return Streams.concat(existingKeysToRetain.stream(), Stream.of(newKey)); } private CloudEncryptionKey makeNewKey(Integer siteId, KeyIdGenerator idGenerator) { diff --git a/src/main/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategy.java b/src/main/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategy.java index 10c04b6f..54a9c724 100644 --- a/src/main/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategy.java +++ b/src/main/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategy.java @@ -3,13 +3,12 @@ import com.uid2.admin.store.Clock; import com.uid2.shared.model.CloudEncryptionKey; -import java.util.ArrayList; import java.util.Comparator; import java.util.Set; import java.util.stream.Collectors; -import java.util.stream.Stream; -// Keep up to `expiredKeysToRetain` most recent expired keys +// Only keep keys past activation time - no future keys allowed +// Keep up to `expiredKeysToRetain` most recent keys // Considers all keys passed, doesn't do grouping by site (handled upstream) public class ExpiredKeyCountRetentionStrategy implements CloudKeyRetentionStrategy { private final Clock clock; @@ -22,37 +21,10 @@ public ExpiredKeyCountRetentionStrategy(Clock clock, int expiredKeysToRetain) { @Override public Set selectKeysToRetain(Set keysForSite) { - var activeKey = findActiveKey(keysForSite); - if (activeKey == null) { - return keysForSite; - } - - var expiredKeys = new ArrayList(); - var nonExpiredKeys = new ArrayList(); - for (var key : keysForSite) { - if (key.getActivates() < activeKey.getActivates()) { - expiredKeys.add(key); - } else { - nonExpiredKeys.add(key); - } - } - - var retainedExpiredKeys = pickRetainedExpiredKeys(expiredKeys); - return Stream.concat(retainedExpiredKeys, nonExpiredKeys.stream()).collect(Collectors.toSet()); - - } - - private Stream pickRetainedExpiredKeys(ArrayList expiredKeys) { - return expiredKeys - .stream() + return keysForSite.stream() + .filter(key -> key.getActivates() <= clock.getEpochSecond()) .sorted(Comparator.comparingLong(CloudEncryptionKey::getActivates).reversed()) - .limit(expiredKeysToRetain); - } - - private CloudEncryptionKey findActiveKey(Set keys) { - var keysPastActivation = keys.stream().filter(key -> key.getActivates() <= clock.getEpochSecond()); - return keysPastActivation - .max(Comparator.comparingLong(CloudEncryptionKey::getActivates)) - .orElse(null); + .limit(expiredKeysToRetain) + .collect(Collectors.toSet()); } } \ No newline at end of file diff --git a/src/main/java/com/uid2/admin/vertx/service/CloudEncryptionKeyService.java b/src/main/java/com/uid2/admin/vertx/service/CloudEncryptionKeyService.java index fb89c211..a18efd2b 100644 --- a/src/main/java/com/uid2/admin/vertx/service/CloudEncryptionKeyService.java +++ b/src/main/java/com/uid2/admin/vertx/service/CloudEncryptionKeyService.java @@ -50,7 +50,7 @@ public void setupRoutes(Router router) { ); router.post(Endpoints.CLOUD_ENCRYPTION_KEY_ROTATE.toString()).handler( - auth.handle(this::handleRotate, Role.MAINTAINER) + auth.handle(this::handleRotate, Role.MAINTAINER, Role.SECRET_ROTATION) ); } diff --git a/src/test/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategyTest.java b/src/test/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategyTest.java index 26a6f980..33a6fcab 100644 --- a/src/test/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategyTest.java +++ b/src/test/java/com/uid2/admin/cloudEncryption/ExpiredKeyCountRetentionStrategyTest.java @@ -16,15 +16,13 @@ class ExpiredKeyCountRetentionStrategyTest { private final long past1 = 100L; private final long past2 = 200L; - private final long past3 = 300L; - private final long now = 400L; - private final long future = 500L; + private final long now = 300L; + private final long future = 400L; private ExpiredKeyCountRetentionStrategy strategy; - private Clock clock; @BeforeEach void setUp() { - clock = mock(Clock.class); + Clock clock = mock(Clock.class); strategy = new ExpiredKeyCountRetentionStrategy(clock, 2); when(clock.getEpochSecond()).thenReturn(now); } @@ -50,29 +48,27 @@ void selectKeysToRetain_withLessThanNKeys() { } @Test - void selectKeysToRetain_withMoreThanNNonExpiredKeys() { + void selectKeysToRetain_dropsFutureKeys() { var activeKey = new CloudEncryptionKey(1, 1, now, now, "secret 1"); var futureKey1 = new CloudEncryptionKey(2, 1, future, now, "secret 2"); - var futureKey2 = new CloudEncryptionKey(3, 1, future, now, "secret 3"); - var futureKey3 = new CloudEncryptionKey(4, 1, future, now, "secret 4"); - var expiredKey1 = new CloudEncryptionKey(5, 1, past1, now, "secret 5"); - var originalKeys = Set.of(activeKey, futureKey1, futureKey2, futureKey3, expiredKey1); + var expiredKey1 = new CloudEncryptionKey(3, 1, past1, now, "secret 3"); + var originalKeys = Set.of(activeKey, futureKey1, expiredKey1); + + var expected = Set.of(activeKey, expiredKey1); var actual = strategy.selectKeysToRetain(originalKeys); - assertThat(actual).isEqualTo(originalKeys); + assertThat(actual).isEqualTo(expected); } @Test - void selectKeysToRetain_withMoreThanNExpiredKeys() { + void selectKeysToRetain_keepsNMostRecentNonFutureKeys() { var oldestExpiredKey = new CloudEncryptionKey(1, 1, past1, past1, "secret 1"); // Don't retain var expiredKey2 = new CloudEncryptionKey(2, 1, past2, past1, "secret 2"); - var expiredKey3 = new CloudEncryptionKey(3, 1, past3, past1, "secret 3"); - var activeKey = new CloudEncryptionKey(3, 1, now, past1, "secret 4"); - var futureKey = new CloudEncryptionKey(3, 1, now, past1, "secret 5"); - var originalKeys = Set.of(oldestExpiredKey, expiredKey2, expiredKey3, activeKey, futureKey); + var activeKey = new CloudEncryptionKey(3, 1, now, past1, "secret 3"); + var originalKeys = Set.of(oldestExpiredKey, expiredKey2, activeKey); - var expected = Set.of(expiredKey2, expiredKey3, activeKey, futureKey); + var expected = Set.of(expiredKey2, activeKey); var actual = strategy.selectKeysToRetain(originalKeys); diff --git a/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java b/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java index 1e8935ac..200c8343 100644 --- a/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java @@ -111,6 +111,16 @@ public void testRotate_noAccess(Vertx vertx, VertxTestContext testContext) { }); } + @Test + public void testRotate_canBeRotatedBySecretRotationJob(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.SECRET_ROTATION); + post(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_ROTATE, null, response -> { + assertEquals(200, response.statusCode()); + + testContext.completeNow(); + }); + } + @Test public void testRotate_noSitesDoesNothing(Vertx vertx, VertxTestContext testContext) { fakeAuth(Role.MAINTAINER);