diff --git a/pom.xml b/pom.xml index 3e598314..37f83aa4 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.uid2 uid2-admin - 5.18.7 + 5.18.8-alpha-127-SNAPSHOT UTF-8 diff --git a/src/main/java/com/uid2/admin/Main.java b/src/main/java/com/uid2/admin/Main.java index a5cfebf4..070c0f3d 100644 --- a/src/main/java/com/uid2/admin/Main.java +++ b/src/main/java/com/uid2/admin/Main.java @@ -5,7 +5,7 @@ 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.CloudKeyStatePlanner; import com.uid2.admin.cloudencryption.ExpiredKeyCountRetentionStrategy; import com.uid2.admin.job.JobDispatcher; import com.uid2.admin.job.jobsync.EncryptedFilesSyncJob; @@ -248,9 +248,9 @@ 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); + var cloudEncryptionKeyRotationStrategy = new CloudKeyStatePlanner(cloudEncryptionSecretGenerator, clock, cloudEncryptionKeyRetentionStrategy); + var cloudEncryptionKeyManager = new CloudEncryptionKeyManager(rotatingCloudEncryptionKeyProvider, cloudEncryptionKeyStoreWriter, operatorKeyProvider, cloudEncryptionKeyRotationStrategy); IService[] services = { new ClientKeyService(config, auth, writeLock, clientKeyStoreWriter, clientKeyProvider, siteProvider, keysetManager, keyGenerator, keyHasher), @@ -268,7 +268,7 @@ public void run() { new PrivateSiteDataRefreshService(auth, jobDispatcher, writeLock, config, rotatingCloudEncryptionKeyProvider), new JobDispatcherService(auth, jobDispatcher), new SearchService(auth, clientKeyProvider, operatorKeyProvider), - new CloudEncryptionKeyService(auth, rotatingCloudEncryptionKeyProvider, cloudEncryptionKeyStoreWriter, operatorKeyProvider, cloudEncryptionKeyRotationStrategy) + new CloudEncryptionKeyService(auth, cloudEncryptionKeyManager) }; @@ -293,11 +293,7 @@ public void run() { } synchronized (writeLock) { - cloudEncryptionKeyManager.generateKeysForOperators( - operatorKeyProvider.getAll(), - config.getLong("cloud_encryption_key_activates_in_seconds"), - config.getInteger("cloud_encryption_key_count_per_site") - ); + cloudEncryptionKeyManager.backfillKeys(); rotatingCloudEncryptionKeyProvider.loadContent(); } diff --git a/src/main/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyDiff.java b/src/main/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyDiff.java new file mode 100644 index 00000000..7e68cb7f --- /dev/null +++ b/src/main/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyDiff.java @@ -0,0 +1,46 @@ +package com.uid2.admin.cloudencryption; + +import com.uid2.shared.model.CloudEncryptionKey; + +import java.text.MessageFormat; +import java.util.HashSet; +import java.util.Set; + +public record CloudEncryptionKeyDiff( + int before, + int after, + int created, + int removed, + int unchanged +) { + public static CloudEncryptionKeyDiff calculateDiff(Set keysBefore, Set keysAfter) { + var before = keysBefore.size(); + var after = keysAfter.size(); + + var intersection = new HashSet<>(keysBefore); + intersection.retainAll(keysAfter); + int unchanged = intersection.size(); + + var onlyInLeft = new HashSet<>(keysBefore); + onlyInLeft.removeAll(keysAfter); + int removed = onlyInLeft.size(); + + var onlyInRight = new HashSet<>(keysAfter); + onlyInRight.removeAll(keysBefore); + int created = onlyInRight.size(); + + return new CloudEncryptionKeyDiff(before, after, created, removed, unchanged); + } + + @Override + public String toString() { + return MessageFormat.format( + "before={0}, after={1}, created={2}, removed={3}, unchanged={4}", + before, + after, + created, + removed, + unchanged + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyManager.java b/src/main/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyManager.java index d2b6a90b..1552f3a5 100644 --- a/src/main/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyManager.java +++ b/src/main/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyManager.java @@ -1,109 +1,95 @@ package com.uid2.admin.cloudencryption; +import com.uid2.admin.model.CloudEncryptionKeySummary; import com.uid2.admin.store.writer.CloudEncryptionKeyStoreWriter; import com.uid2.shared.auth.OperatorKey; +import com.uid2.shared.auth.RotatingOperatorKeyProvider; import com.uid2.shared.model.CloudEncryptionKey; import com.uid2.shared.store.reader.RotatingCloudEncryptionKeyProvider; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Metrics; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.time.Instant; import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; public class CloudEncryptionKeyManager { + private final RotatingCloudEncryptionKeyProvider keyProvider; + private final RotatingOperatorKeyProvider operatorKeyProvider; + private final CloudEncryptionKeyStoreWriter keyWriter; + private final CloudKeyStatePlanner planner; + private Set operatorKeys; + private Set existingKeys; private static final Logger LOGGER = LoggerFactory.getLogger(CloudEncryptionKeyManager.class); - private final RotatingCloudEncryptionKeyProvider RotatingCloudEncryptionKeyProvider; - private final CloudEncryptionKeyStoreWriter cloudEncryptionKeyStoreWriter; - private final CloudSecretGenerator secretGenerator; - public CloudEncryptionKeyManager( - RotatingCloudEncryptionKeyProvider RotatingCloudEncryptionKeyProvider, - CloudEncryptionKeyStoreWriter cloudEncryptionKeyStoreWriter, - CloudSecretGenerator keyGenerator - ) { - this.RotatingCloudEncryptionKeyProvider = RotatingCloudEncryptionKeyProvider; - this.cloudEncryptionKeyStoreWriter = cloudEncryptionKeyStoreWriter; - this.secretGenerator = keyGenerator; - } - - // Ensures there are `keyCountPerSite` sites for each site corresponding of operatorKeys. If there are less - create new ones. - // Give all new keys for each site `activationInterval` seconds between activations, starting now - public void generateKeysForOperators( - Collection operatorKeys, - long activationInterval, - int keyCountPerSite - ) throws Exception { - this.RotatingCloudEncryptionKeyProvider.loadContent(); - - if (operatorKeys == null || operatorKeys.isEmpty()) { - throw new IllegalArgumentException("Operator keys collection must not be null or empty"); - } - if (activationInterval <= 0) { - throw new IllegalArgumentException("Key activate interval must be greater than zero"); - } - if (keyCountPerSite <= 0) { - throw new IllegalArgumentException("Key count per site must be greater than zero"); - } - - for (Integer siteId : uniqueSiteIdsForOperators(operatorKeys)) { - ensureEnoughKeysForSite(activationInterval, keyCountPerSite, siteId); - } + RotatingCloudEncryptionKeyProvider keyProvider, + CloudEncryptionKeyStoreWriter keyWriter, + RotatingOperatorKeyProvider operatorKeyProvider, + CloudKeyStatePlanner planner) { + this.keyProvider = keyProvider; + this.operatorKeyProvider = operatorKeyProvider; + this.keyWriter = keyWriter; + this.planner = planner; } - private void ensureEnoughKeysForSite(long activationInterval, int keyCountPerSite, Integer siteId) throws Exception { - // Check if the site ID already exists in the S3 key provider and has fewer than the required number of keys - int currentKeyCount = countKeysForSite(siteId); - if (currentKeyCount >= keyCountPerSite) { - LOGGER.info("Site ID {} already has the required number of keys. Skipping key generation.", siteId); - return; - } - - int keysToGenerate = keyCountPerSite - currentKeyCount; - for (int i = 0; i < keysToGenerate; i++) { - addKey(activationInterval, siteId, i); + // For any site that has an operator create a new key activating now + // Keep up to 5 most recent old keys per site, delete the rest + public void rotateKeys() throws Exception { + boolean success = false; + try { + refreshCloudData(); + var desiredKeys = planner.planRotation(existingKeys, operatorKeys); + writeKeys(desiredKeys); + success = true; + var diff = CloudEncryptionKeyDiff.calculateDiff(existingKeys, desiredKeys); + LOGGER.info("Key rotation complete. Diff: {}", diff); + } catch (Exception e) { + success = false; + LOGGER.error("Key rotation failed", e); + throw e; + } finally { + Counter.builder("uid2.cloud_encryption_key_manager.rotations") + .tag("success", Boolean.toString(success)) + .description("The number of times rotations have happened") + .register(Metrics.globalRegistry); } - LOGGER.info("Generated {} keys for site ID {}", keysToGenerate, siteId); - } - - private void addKey(long keyActivateInterval, Integer siteId, int keyIndex) throws Exception { - long created = Instant.now().getEpochSecond(); - long activated = created + (keyIndex * keyActivateInterval); - CloudEncryptionKey cloudEncryptionKey = generateCloudEncryptionKey(siteId, activated, created); - addCloudEncryptionKey(cloudEncryptionKey); } - private static Set uniqueSiteIdsForOperators(Collection operatorKeys) { - Set uniqueSiteIds = new HashSet<>(); - for (OperatorKey operatorKey : operatorKeys) { - uniqueSiteIds.add(operatorKey.getSiteId()); + // For any site that has an operator, if there are no keys, create a key activating now + public void backfillKeys() throws Exception { + try { + refreshCloudData(); + var desiredKeys = planner.planBackfill(existingKeys, operatorKeys); + writeKeys(desiredKeys); + var diff = CloudEncryptionKeyDiff.calculateDiff(existingKeys, desiredKeys); + LOGGER.info("Key backfill complete. Diff: {}", diff); + } catch (Exception e) { + LOGGER.error("Key backfill failed", e); + throw e; } - return uniqueSiteIds; - } - - CloudEncryptionKey generateCloudEncryptionKey(int siteId, long activates, long created) throws Exception { - int newKeyId = getNextKeyId(); - String secret = secretGenerator.generate(); - return new CloudEncryptionKey(newKeyId, siteId, activates, created, secret); } - void addCloudEncryptionKey(CloudEncryptionKey cloudEncryptionKey) throws Exception { - Map cloudEncryptionKeys = new HashMap<>(RotatingCloudEncryptionKeyProvider.getAll()); - cloudEncryptionKeys.put(cloudEncryptionKey.getId(), cloudEncryptionKey); - cloudEncryptionKeyStoreWriter.upload(cloudEncryptionKeys, null); + public Set getKeySummaries() throws Exception { + refreshCloudData(); + return existingKeys.stream().map(CloudEncryptionKeySummary::fromFullKey).collect(Collectors.toSet()); } - int getNextKeyId() { - Map cloudEncryptionKeys = RotatingCloudEncryptionKeyProvider.getAll(); - if (cloudEncryptionKeys == null || cloudEncryptionKeys.isEmpty()) { - return 1; - } - return cloudEncryptionKeys.keySet().stream().max(Integer::compareTo).orElse(0) + 1; + private void writeKeys(Set desiredKeys) throws Exception { + var keysForWriting = desiredKeys.stream().collect(Collectors.toMap( + CloudEncryptionKey::getId, + Function.identity()) + ); + keyWriter.upload(keysForWriting, null); } - int countKeysForSite(int siteId) { - Map allKeys = RotatingCloudEncryptionKeyProvider.getAll(); - return (int) allKeys.values().stream().filter(key -> key.getSiteId() == siteId).count(); + private void refreshCloudData() throws Exception { + keyProvider.loadContent(); + operatorKeyProvider.loadContent(operatorKeyProvider.getMetadata()); + operatorKeys = new HashSet<>(operatorKeyProvider.getAll()); + existingKeys = new HashSet<>(keyProvider.getAll().values()); } } \ No newline at end of file diff --git a/src/main/java/com/uid2/admin/cloudencryption/CloudKeyRotationStrategy.java b/src/main/java/com/uid2/admin/cloudencryption/CloudKeyStatePlanner.java similarity index 54% rename from src/main/java/com/uid2/admin/cloudencryption/CloudKeyRotationStrategy.java rename to src/main/java/com/uid2/admin/cloudencryption/CloudKeyStatePlanner.java index fec1f636..8dc1bb1c 100644 --- a/src/main/java/com/uid2/admin/cloudencryption/CloudKeyRotationStrategy.java +++ b/src/main/java/com/uid2/admin/cloudencryption/CloudKeyStatePlanner.java @@ -5,18 +5,20 @@ import com.uid2.shared.auth.OperatorKey; import com.uid2.shared.model.CloudEncryptionKey; +import java.text.MessageFormat; import java.util.Collection; +import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; -public class CloudKeyRotationStrategy { +public class CloudKeyStatePlanner { private final CloudSecretGenerator secretGenerator; private final Clock clock; private final CloudKeyRetentionStrategy keyRetentionStrategy; - public CloudKeyRotationStrategy( + public CloudKeyStatePlanner( CloudSecretGenerator secretGenerator, Clock clock, CloudKeyRetentionStrategy keyRetentionStrategy @@ -26,24 +28,32 @@ public CloudKeyRotationStrategy( this.keyRetentionStrategy = keyRetentionStrategy; } - public Set computeDesiredKeys( - Collection existingCloudKeys, - Collection operatorKeys + public Set planRotation( + Set existingKeys, + Set operatorKeys ) { - var keyGenerator = new CloudEncryptionKeyGenerator(clock, secretGenerator, existingCloudKeys); - Map> existingKeysBySite = existingCloudKeys + var keyGenerator = new CloudEncryptionKeyGenerator(clock, secretGenerator, existingKeys); + Map> existingKeysBySite = existingKeys .stream() .collect(Collectors.groupingBy(CloudEncryptionKey::getSiteId, Collectors.toSet())); - return operatorKeys - .stream() - .map(OperatorKey::getSiteId) - .distinct() - .flatMap(siteId -> desiredKeysForSite(siteId, keyGenerator, existingKeysBySite.getOrDefault(siteId, Set.of()))) + return siteIdsWithOperators(operatorKeys) + .flatMap(siteId -> planRotationForSite(siteId, keyGenerator, existingKeysBySite.getOrDefault(siteId, Set.of()))) .collect(Collectors.toSet()); } - private Stream desiredKeysForSite( + public Set planBackfill( + Set existingKeys, + Set operatorKeys + ) { + var keyGenerator = new CloudEncryptionKeyGenerator(clock, secretGenerator, existingKeys); + var siteIdsWithKeys = existingKeys.stream().map(CloudEncryptionKey::getSiteId).collect(Collectors.toSet()); + var sitesWithoutKeys = siteIdsWithOperators(operatorKeys).filter(siteId -> !siteIdsWithKeys.contains(siteId)); + var newKeys = sitesWithoutKeys.map(keyGenerator::makeNewKey); + return Streams.concat(existingKeys.stream(), newKeys).collect(Collectors.toSet()); + } + + private Stream planRotationForSite( Integer siteId, CloudEncryptionKeyGenerator keyGenerator, Set existingKeys @@ -52,4 +62,11 @@ private Stream desiredKeysForSite( var newKey = keyGenerator.makeNewKey(siteId); return Streams.concat(existingKeysToRetain.stream(), Stream.of(newKey)); } + + private static Stream siteIdsWithOperators(Collection operatorKeys) { + return operatorKeys + .stream() + .map(OperatorKey::getSiteId) + .distinct(); + } } diff --git a/src/main/java/com/uid2/admin/model/CloudEncryptionKeyListResponse.java b/src/main/java/com/uid2/admin/model/CloudEncryptionKeyListResponse.java index e62b4899..09e549ce 100644 --- a/src/main/java/com/uid2/admin/model/CloudEncryptionKeyListResponse.java +++ b/src/main/java/com/uid2/admin/model/CloudEncryptionKeyListResponse.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; +import java.util.Set; public record CloudEncryptionKeyListResponse( - @JsonProperty List cloudEncryptionKeys + @JsonProperty Set cloudEncryptionKeys ) {} 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 f98add26..83e8a939 100644 --- a/src/main/java/com/uid2/admin/vertx/service/CloudEncryptionKeyService.java +++ b/src/main/java/com/uid2/admin/vertx/service/CloudEncryptionKeyService.java @@ -3,44 +3,26 @@ 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.cloudencryption.CloudEncryptionKeyManager; 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.auth.RotatingOperatorKeyProvider; -import com.uid2.shared.model.CloudEncryptionKey; -import com.uid2.shared.store.reader.RotatingCloudEncryptionKeyProvider; 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.Set; -import java.util.function.Function; -import java.util.stream.Collectors; - public class CloudEncryptionKeyService implements IService { private final AdminAuthMiddleware auth; - private final RotatingCloudEncryptionKeyProvider keyProvider; - private final RotatingOperatorKeyProvider operatorKeyProvider; - private final CloudEncryptionKeyStoreWriter keyWriter; - private final CloudKeyRotationStrategy rotationStrategy; + private final CloudEncryptionKeyManager keyManager; private static final ObjectMapper OBJECT_MAPPER = Mapper.getInstance(); public CloudEncryptionKeyService( AdminAuthMiddleware auth, - RotatingCloudEncryptionKeyProvider keyProvider, - CloudEncryptionKeyStoreWriter keyWriter, - RotatingOperatorKeyProvider operatorKeyProvider, - CloudKeyRotationStrategy rotationStrategy + CloudEncryptionKeyManager keyManager ) { this.auth = auth; - this.keyProvider = keyProvider; - this.operatorKeyProvider = operatorKeyProvider; - this.keyWriter = keyWriter; - this.rotationStrategy = rotationStrategy; + this.keyManager = keyManager; } @Override @@ -56,38 +38,16 @@ public void setupRoutes(Router router) { private void handleRotate(RoutingContext rc) { try { - keyProvider.loadContent(); - operatorKeyProvider.loadContent(operatorKeyProvider.getMetadata()); - var operatorKeys = operatorKeyProvider.getAll(); - var existingKeys = keyProvider.getAll().values(); - - var desiredKeys = rotationStrategy.computeDesiredKeys(existingKeys, operatorKeys); - writeKeys(desiredKeys); - + keyManager.rotateKeys(); 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() - .map(CloudEncryptionKeySummary::fromFullKey) - .toList(); - CloudEncryptionKeyListResponse response = new CloudEncryptionKeyListResponse(keySummaries); + var response = new CloudEncryptionKeyListResponse(keyManager.getKeySummaries()); respondWithJson(rc, response); } catch (Exception e) { rc.fail(500, e); diff --git a/src/main/java/com/uid2/admin/vertx/service/OperatorKeyService.java b/src/main/java/com/uid2/admin/vertx/service/OperatorKeyService.java index 0c42827d..45933957 100644 --- a/src/main/java/com/uid2/admin/vertx/service/OperatorKeyService.java +++ b/src/main/java/com/uid2/admin/vertx/service/OperatorKeyService.java @@ -47,8 +47,6 @@ public class OperatorKeyService implements IService { private final KeyHasher keyHasher; private final String operatorKeyPrefix; private final CloudEncryptionKeyManager cloudEncryptionKeyManager; - private final long cloudEncryptionKeyActivatesInSeconds; - private final int cloudEncryptionKeyCountPerSite; public OperatorKeyService(JsonObject config, AdminAuthMiddleware auth, @@ -69,8 +67,6 @@ public OperatorKeyService(JsonObject config, this.cloudEncryptionKeyManager = cloudEncryptionKeyManager; this.operatorKeyPrefix = config.getString("operator_key_prefix"); - this.cloudEncryptionKeyActivatesInSeconds = config.getLong("cloud_encryption_key_activates_in_seconds",0L); - this.cloudEncryptionKeyCountPerSite = config.getInteger("cloud_encryption_key_count_per_site",0); } @Override @@ -274,7 +270,8 @@ private void handleOperatorAdd(RoutingContext rc) { // upload to storage operatorKeyStoreWriter.upload(operators); - cloudEncryptionKeyManager.generateKeysForOperators(Collections.singletonList(newOperator), cloudEncryptionKeyActivatesInSeconds, cloudEncryptionKeyCountPerSite); + // generate cloud encryption keys as needed + cloudEncryptionKeyManager.backfillKeys(); // respond with new key rc.response().end(JSON_WRITER.writeValueAsString(new RevealedKey<>(newOperator, key))); @@ -413,7 +410,7 @@ private void handleOperatorUpdate(RoutingContext rc) { operatorKeyStoreWriter.upload(operators); if (siteIdChanged) { - cloudEncryptionKeyManager.generateKeysForOperators(Collections.singletonList(existingOperator), cloudEncryptionKeyActivatesInSeconds, cloudEncryptionKeyCountPerSite); + cloudEncryptionKeyManager.backfillKeys(); } // return the updated client diff --git a/src/test/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyDiffTest.java b/src/test/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyDiffTest.java new file mode 100644 index 00000000..ff59cb27 --- /dev/null +++ b/src/test/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyDiffTest.java @@ -0,0 +1,54 @@ +package com.uid2.admin.cloudencryption; + +import com.uid2.shared.model.CloudEncryptionKey; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class CloudEncryptionKeyDiffTest { + private final int siteId = 1; + private final String secret1 = "secret 1"; + private final CloudEncryptionKey key1 = new CloudEncryptionKey(1, siteId, 0, 0, secret1); + private final CloudEncryptionKey key2 = new CloudEncryptionKey(2, siteId, 0, 0, secret1); + private final CloudEncryptionKey key3 = new CloudEncryptionKey(3, siteId, 0, 0, secret1); + + + @Test + void calculateDiff_noKeys() { + var expected = new CloudEncryptionKeyDiff(0, 0, 0, 0, 0); + + var actual = CloudEncryptionKeyDiff.calculateDiff(Set.of(), Set.of()); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void calculateDiff_noChange() { + var expected = new CloudEncryptionKeyDiff(2, 2, 0, 0, 2); + + var actual = CloudEncryptionKeyDiff.calculateDiff(Set.of(key1, key2), Set.of(key1, key2)); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void calculateDiff_keysCreated() { + var expected = new CloudEncryptionKeyDiff(2, 3, 1, 0, 2); + + var actual = CloudEncryptionKeyDiff.calculateDiff(Set.of(key1, key2), Set.of(key1, key2, key3)); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void calculateDiff_keysRemoved() { + var expected = new CloudEncryptionKeyDiff(3, 1, 0, 2, 1); + + var actual = CloudEncryptionKeyDiff.calculateDiff(Set.of(key1, key2, key3), Set.of(key1)); + + assertThat(actual).isEqualTo(expected); + } +} \ No newline at end of file diff --git a/src/test/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyManagerTest.java b/src/test/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyManagerTest.java deleted file mode 100644 index 0ee89907..00000000 --- a/src/test/java/com/uid2/admin/cloudencryption/CloudEncryptionKeyManagerTest.java +++ /dev/null @@ -1,248 +0,0 @@ -package com.uid2.admin.cloudencryption; - -import com.uid2.admin.store.writer.CloudEncryptionKeyStoreWriter; -import com.uid2.shared.auth.OperatorKey; -import com.uid2.shared.model.CloudEncryptionKey; -import com.uid2.shared.store.reader.RotatingCloudEncryptionKeyProvider; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -class CloudEncryptionKeyManagerTest { - private RotatingCloudEncryptionKeyProvider cloudEncryptionKeyProvider; - private CloudEncryptionKeyStoreWriter cloudEncryptionKeyStoreWriter; - private CloudSecretGenerator keyGenerator; - private CloudEncryptionKeyManager cloudEncryptionKeyManager; - - private final long keyActivateInterval = 3600; // 1 hour - private final int keyCountPerSite = 3; - private final int siteId = 1; - - @BeforeEach - void setUp() { - cloudEncryptionKeyProvider = mock(RotatingCloudEncryptionKeyProvider.class); - cloudEncryptionKeyStoreWriter = mock(CloudEncryptionKeyStoreWriter.class); - keyGenerator = mock(CloudSecretGenerator.class); - cloudEncryptionKeyManager = new CloudEncryptionKeyManager(cloudEncryptionKeyProvider, cloudEncryptionKeyStoreWriter, keyGenerator); - } - - @Test - void testGenerateCloudEncryptionKey() throws Exception { - when(keyGenerator.generate()).thenReturn("randomKeyString"); - - CloudEncryptionKey cloudEncryptionKey = cloudEncryptionKeyManager.generateCloudEncryptionKey(siteId, 1000L, 2000L); - - assertNotNull(cloudEncryptionKey); - assertEquals(siteId, cloudEncryptionKey.getSiteId()); - assertEquals(1000L, cloudEncryptionKey.getActivates()); - assertEquals(2000L, cloudEncryptionKey.getCreated()); - assertEquals("randomKeyString", cloudEncryptionKey.getSecret()); - } - - @Test - void testAddCloudEncryptionKeyToEmpty() throws Exception { - CloudEncryptionKey cloudEncryptionKey = new CloudEncryptionKey(1, siteId, 1000L, 2000L, "randomKeyString"); - - Map existingKeys = new HashMap<>(); - when(cloudEncryptionKeyProvider.getAll()).thenReturn(existingKeys); - - cloudEncryptionKeyManager.addCloudEncryptionKey(cloudEncryptionKey); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Map.class); - verify(cloudEncryptionKeyStoreWriter).upload(captor.capture(), isNull()); - - Map capturedKeys = captor.getValue(); - assertEquals(1, capturedKeys.size()); - assertEquals(cloudEncryptionKey, capturedKeys.get(1)); - } - - @Test - void testAddCloudEncryptionKeyToExisting() throws Exception { - CloudEncryptionKey cloudEncryptionKey = new CloudEncryptionKey(3, siteId, 1000L, 2000L, "randomKeyString"); - - Map existingKeys = new HashMap<>(); - CloudEncryptionKey existingKey1 = new CloudEncryptionKey(1, siteId, 500L, 1500L, "existingSecret1"); - CloudEncryptionKey existingKey2 = new CloudEncryptionKey(2, siteId, 600L, 1600L, "existingSecret2"); - existingKeys.put(1, existingKey1); - existingKeys.put(2, existingKey2); - - when(cloudEncryptionKeyProvider.getAll()).thenReturn(existingKeys); - - cloudEncryptionKeyManager.addCloudEncryptionKey(cloudEncryptionKey); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Map.class); - verify(cloudEncryptionKeyStoreWriter).upload(captor.capture(), isNull()); - - Map capturedKeys = captor.getValue(); - - assertEquals(3, capturedKeys.size()); - assertEquals(existingKey1, capturedKeys.get(1)); - assertEquals(existingKey2, capturedKeys.get(2)); - assertEquals(cloudEncryptionKey, capturedKeys.get(3)); - } - - @Test - void testGetNextKeyId() { - Map existingKeys = new HashMap<>(); - existingKeys.put(1, new CloudEncryptionKey(1, siteId, 500L, 1500L, "existingSecret1")); - when(cloudEncryptionKeyProvider.getAll()).thenReturn(existingKeys); - - int nextKeyId = cloudEncryptionKeyManager.getNextKeyId(); - - assertEquals(2, nextKeyId); - } - - @Test - void testAddCloudEncryptionKey() throws Exception { - CloudEncryptionKey cloudEncryptionKey = new CloudEncryptionKey(1, siteId, 1000L, 2000L, "randomKeyString"); - - Map existingKeys = new HashMap<>(); - when(cloudEncryptionKeyProvider.getAll()).thenReturn(existingKeys); - - cloudEncryptionKeyManager.addCloudEncryptionKey(cloudEncryptionKey); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Map.class); - verify(cloudEncryptionKeyStoreWriter).upload(captor.capture(), isNull()); - - Map capturedKeys = captor.getValue(); - assertEquals(1, capturedKeys.size()); - assertEquals(cloudEncryptionKey, capturedKeys.get(1)); - } - - @Test - void testCountKeysForSite() { - Map testKeys = new HashMap<>(); - testKeys.put(1, new CloudEncryptionKey(1, 1, 1000L, 900L, "key1")); - testKeys.put(2, new CloudEncryptionKey(2, 1, 1100L, 1000L, "key2")); - testKeys.put(3, new CloudEncryptionKey(3, 2, 1200L, 1100L, "key3")); - testKeys.put(4, new CloudEncryptionKey(4, 1, 1300L, 1200L, "key4")); - - when(cloudEncryptionKeyProvider.getAll()).thenReturn(testKeys); - - int countForSite1 = cloudEncryptionKeyManager.countKeysForSite(1); - int countForSite2 = cloudEncryptionKeyManager.countKeysForSite(2); - int countForSite3 = cloudEncryptionKeyManager.countKeysForSite(3); - - assertEquals(3, countForSite1); - assertEquals(1, countForSite2); - assertEquals(0, countForSite3); - } - - @Test - void testGenerateKeysForOperators() throws Exception { - Collection operatorKeys = Arrays.asList( - createOperatorKey("hash1", 100), - createOperatorKey("hash2", 100), - createOperatorKey("hash3", 200) - ); - - Map existingKeys = new HashMap<>(); - existingKeys.put(1, new CloudEncryptionKey(1, 100, 1000L, 900L, "existingKey1")); - when(cloudEncryptionKeyProvider.getAll()).thenReturn(existingKeys); - - when(keyGenerator.generate()).thenReturn("generatedSecret"); - - cloudEncryptionKeyManager.generateKeysForOperators(operatorKeys, keyActivateInterval, keyCountPerSite); - - verify(cloudEncryptionKeyProvider, times(1)).loadContent(); - - ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); - // 6 keys needed - 1 existed keys = 5 new keys - verify(cloudEncryptionKeyStoreWriter, times(5)).upload(mapCaptor.capture(), isNull()); - } - - @Test - void testGenerateKeysForOperators_NoNewKeysNeeded() throws Exception { - Collection operatorKeys = Collections.singletonList( - createOperatorKey("hash1", 100) - ); - - Map existingKeys = new HashMap<>(); - existingKeys.put(1, new CloudEncryptionKey(1, 100, 1000L, 900L, "existingKey1")); - existingKeys.put(2, new CloudEncryptionKey(2, 100, 2000L, 1900L, "existingKey2")); - existingKeys.put(3, new CloudEncryptionKey(3, 100, 3000L, 2900L, "existingKey3")); - when(cloudEncryptionKeyProvider.getAll()).thenReturn(existingKeys); - - cloudEncryptionKeyManager.generateKeysForOperators(operatorKeys, keyActivateInterval, keyCountPerSite); - - verify(cloudEncryptionKeyStoreWriter, never()).upload(any(), any()); - } - - @Test - void testGenerateKeysForOperators_EmptyOperatorKeys() { - Collection operatorKeys = Collections.emptyList(); - - assertThrows(IllegalArgumentException.class, () -> - cloudEncryptionKeyManager.generateKeysForOperators(operatorKeys, keyActivateInterval, keyCountPerSite) - ); - } - - @Test - void testGenerateKeysForOperators_InvalidKeyActivateInterval() { - Collection operatorKeys = Collections.singletonList( - createOperatorKey("hash1", 100) - ); - long keyActivateInterval = 0; - - assertThrows(IllegalArgumentException.class, () -> - cloudEncryptionKeyManager.generateKeysForOperators(operatorKeys, keyActivateInterval, keyCountPerSite) - ); - } - - @Test - void testGenerateKeysForOperators_InvalidKeyCountPerSite() { - Collection operatorKeys = Collections.singletonList( - createOperatorKey("hash1", 100) - ); - int keyCountPerSite = 0; - - assertThrows(IllegalArgumentException.class, () -> - cloudEncryptionKeyManager.generateKeysForOperators(operatorKeys, keyActivateInterval, keyCountPerSite) - ); - } - - @Test - void testGenerateKeysForOperators_MultipleSitesWithVaryingExistingKeys() throws Exception { - Collection operatorKeys = Arrays.asList( - createOperatorKey("hash1", 100), - createOperatorKey("hash2", 200), - createOperatorKey("hash3", 300) - ); - - Map existingKeys = new HashMap<>(); - existingKeys.put(1, new CloudEncryptionKey(1, 100, 1000L, 900L, "existingKey1")); - existingKeys.put(2, new CloudEncryptionKey(2, 200, 2000L, 1900L, "existingKey2")); - existingKeys.put(3, new CloudEncryptionKey(3, 200, 3000L, 2900L, "existingKey3")); - when(cloudEncryptionKeyProvider.getAll()).thenReturn(existingKeys); - - when(keyGenerator.generate()).thenReturn("generatedSecret"); - - cloudEncryptionKeyManager.generateKeysForOperators(operatorKeys, keyActivateInterval, keyCountPerSite); - - ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); - // 9 keys needed - 3 existed keys = 6 new keys - verify(cloudEncryptionKeyStoreWriter, times(6)).upload(mapCaptor.capture(), isNull()); - } - - private OperatorKey createOperatorKey(String keyHash, int siteId) { - return new OperatorKey( - keyHash, - "salt", - "name", - "contact", - "protocol", - System.currentTimeMillis(), - false, - siteId, - Collections.emptySet(), - null, - "keyId" - ); - } -} diff --git a/src/test/java/com/uid2/admin/cloudencryption/CloudKeyStatePlannerTest.java b/src/test/java/com/uid2/admin/cloudencryption/CloudKeyStatePlannerTest.java new file mode 100644 index 00000000..8eb52662 --- /dev/null +++ b/src/test/java/com/uid2/admin/cloudencryption/CloudKeyStatePlannerTest.java @@ -0,0 +1,69 @@ +package com.uid2.admin.cloudencryption; + +import com.uid2.admin.store.Clock; +import com.uid2.shared.auth.OperatorKey; +import com.uid2.shared.model.CloudEncryptionKey; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +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 CloudKeyStatePlannerTest { + private CloudSecretGenerator secretGenerator; + private Clock clock; + private ExpiredKeyCountRetentionStrategy retentionStrategy; + private CloudKeyStatePlanner planner; + + private final int siteId = 1; + private final String secret1 = "secret 1"; + private final CloudEncryptionKey key1 = new CloudEncryptionKey(1, siteId, 0, 0, secret1); + private final OperatorKey operatorKey1 = new OperatorKey("hash 1", "salt 1", "name 1", "contact 1", "protocol 1", 0, false, siteId, "one"); + + @BeforeEach + void setUp() { + secretGenerator = mock(CloudSecretGenerator.class); + when(secretGenerator.generate()).thenReturn(secret1); + clock = mock(Clock.class); + retentionStrategy = new ExpiredKeyCountRetentionStrategy(clock, 3); + planner = new CloudKeyStatePlanner(secretGenerator, clock, retentionStrategy); + } + + @Test + void planBackfill_doesNotRemoveKeysWhenOperatorsForKeyMissing() { + // We do cleanup in rotation, this job is supposed to be extremely safe and won't delete anything + var existingCloudKeys = Set.of(key1); + var operatorKeys = Set.of(); + var expected = Set.of(key1); + + var actual = planner.planBackfill(existingCloudKeys, operatorKeys); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void planBackfill_createsNoNewKeysForOperatorsWithExistingKeys() { + var existingCloudKeys = Set.of(key1); + var operatorKeys = Set.of(operatorKey1); + var expected = Set.of(key1); + + var actual = planner.planBackfill(existingCloudKeys, operatorKeys); + + assertThat(actual).isEqualTo(expected); + } + + @Test + void planBackfill_createsNewKeysForOperatorsWithNoKeys() { + var existingCloudKeys = Set.of(); + var operatorKeys = Set.of(operatorKey1); + var expected = Set.of(key1); + + var actual = planner.planBackfill(existingCloudKeys, operatorKeys); + + assertThat(actual).isEqualTo(expected); + } +} \ No newline at end of file diff --git a/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java b/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java index 6e24237a..44553326 100644 --- a/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/CloudEncryptionKeyServiceTest.java @@ -3,7 +3,8 @@ 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.CloudEncryptionKeyManager; +import com.uid2.admin.cloudencryption.CloudKeyStatePlanner; import com.uid2.admin.cloudencryption.ExpiredKeyCountRetentionStrategy; import com.uid2.admin.model.CloudEncryptionKeyListResponse; import com.uid2.admin.model.CloudEncryptionKeySummary; @@ -22,13 +23,14 @@ import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; public class CloudEncryptionKeyServiceTest extends ServiceTestBase { private static final ObjectMapper OBJECT_MAPPER = Mapper.getInstance(); - private final CloudEncryptionKeyListResponse noKeys = new CloudEncryptionKeyListResponse(List.of()); + private final CloudEncryptionKeyListResponse noKeys = new CloudEncryptionKeyListResponse(Set.of()); private final long longAgo = 0L; private final long before = 100L; private final long now = 200L; @@ -47,15 +49,15 @@ public class CloudEncryptionKeyServiceTest extends ServiceTestBase { @Override protected IService createService() { var retentionStrategy = new ExpiredKeyCountRetentionStrategy(clock, 2); - var rotationStrategy = new CloudKeyRotationStrategy(cloudSecretGenerator, clock, retentionStrategy); - - return new CloudEncryptionKeyService( - auth, + var rotationStrategy = new CloudKeyStatePlanner(cloudSecretGenerator, clock, retentionStrategy); + var manager = new CloudEncryptionKeyManager( cloudEncryptionKeyProvider, cloudEncryptionKeyStoreWriter, operatorKeyProvider, rotationStrategy ); + + return new CloudEncryptionKeyService(auth, manager); } @Test @@ -93,14 +95,15 @@ public void testList_withKeys(Vertx vertx, VertxTestContext testContext) { setCloudEncryptionKeys(key1, key2); - var expected = new CloudEncryptionKeyListResponse(List.of( + var expected = new CloudEncryptionKeyListResponse(Set.of( new CloudEncryptionKeySummary(1, 2, date1Iso, date1Iso), new CloudEncryptionKeySummary(2, 2, date2Iso, date1Iso) )); get(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_LIST, response -> { assertEquals(200, response.statusCode()); - assertEquals(expected, parseKeyListResponse(response)); + var actual = parseKeyListResponse(response); + assertEquals(expected, actual); testContext.completeNow(); }); @@ -231,6 +234,18 @@ key2Id, new CloudEncryptionKey(key2Id, siteId1, now, now, secret2) }); } + @Test + public void testRotate_handleExceptions(Vertx vertx, VertxTestContext testContext) { + fakeAuth(Role.MAINTAINER); + setOperatorKeys(operator1); + when(clock.getEpochSecond()).thenThrow(new RuntimeException("oops")); + + post(vertx, testContext, Endpoints.CLOUD_ENCRYPTION_KEY_ROTATE, null, rotateResponse -> { + assertEquals(500, rotateResponse.statusCode()); + testContext.completeNow(); + }); + } + private static CloudEncryptionKeyListResponse parseKeyListResponse(HttpResponse response) throws JsonProcessingException { return OBJECT_MAPPER.readValue(response.bodyAsString(), new TypeReference<>() { }); @@ -242,7 +257,7 @@ private static OperatorKey testOperatorKey(int siteId, String keyId) { "key salt " + keyId, "name " + keyId, "contact " + keyId, - "protocol " + keyId, + "protocol " + keyId, 0, false, siteId, diff --git a/src/test/java/com/uid2/admin/vertx/OperatorKeyServiceTest.java b/src/test/java/com/uid2/admin/vertx/OperatorKeyServiceTest.java index c0ed1512..c8d0531e 100644 --- a/src/test/java/com/uid2/admin/vertx/OperatorKeyServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/OperatorKeyServiceTest.java @@ -22,7 +22,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -277,11 +276,7 @@ public void operatorAddGeneratesCloudEncryptionKeys(Vertx vertx, VertxTestContex "operatorAddGeneratesCloudEncryptionKeys", () -> assertEquals(200, response.statusCode()), () -> assertNotNull(revealedOperator.getAuthorizable()), - () -> verify(cloudEncryptionKeyManager).generateKeysForOperators( - argThat(collection -> collection.size() == 1 && collection.iterator().next().getName().equals("test_operator")), - eq(3600L), - eq(5) - ) + () -> verify(cloudEncryptionKeyManager).backfillKeys() ); testContext.completeNow(); } catch (Exception e) { @@ -306,11 +301,7 @@ public void operatorUpdateSiteIdGeneratesCloudEncryptionKeys(Vertx vertx, VertxT () -> assertEquals(200, response.statusCode()), () -> assertEquals(5, updatedOperator.getSiteId()), () -> assertNotEquals(1, updatedOperator.getSiteId()), - () -> verify(cloudEncryptionKeyManager).generateKeysForOperators( - argThat(collection -> collection.size() == 1 && collection.iterator().next().getName().equals("test_operator")), - eq(3600L), - eq(5) - ) + () -> verify(cloudEncryptionKeyManager).backfillKeys() ); testContext.completeNow(); } catch (Exception e) { @@ -334,7 +325,7 @@ public void operatorUpdateWithoutSiteIdChangeDoesNotGenerateCloudEncryptionKeys( "operatorUpdateWithoutSiteIdChangeDoesNotGenerateCloudEncryptionKeys", () -> assertEquals(200, response.statusCode()), () -> assertEquals(existingOperator.getSiteId(), updatedOperator.getSiteId()), - () -> verify(cloudEncryptionKeyManager, never()).generateKeysForOperators(any(), anyLong(), anyInt()) + () -> verify(cloudEncryptionKeyManager, never()).backfillKeys() ); testContext.completeNow(); } catch (Exception e) {