diff --git a/src/main/java/com/uid2/admin/Main.java b/src/main/java/com/uid2/admin/Main.java index 25caf056..8bf8d0f7 100644 --- a/src/main/java/com/uid2/admin/Main.java +++ b/src/main/java/com/uid2/admin/Main.java @@ -232,7 +232,7 @@ public void run() { WriteLock writeLock = new WriteLock(); KeyHasher keyHasher = new KeyHasher(); IKeypairGenerator keypairGenerator = new SecureKeypairGenerator(); - ISaltRotation saltRotation = new SaltRotation(config, keyGenerator); + ISaltRotation saltRotation = new SaltRotation(keyGenerator); EncryptionKeyService encryptionKeyService = new EncryptionKeyService( config, auth, writeLock, encryptionKeyStoreWriter, keysetKeyStoreWriter, keyProvider, keysetKeysProvider, adminKeysetProvider, adminKeysetStoreWriter, keyGenerator, clock); KeysetManager keysetManager = new KeysetManager( diff --git a/src/main/java/com/uid2/admin/secret/ISaltRotation.java b/src/main/java/com/uid2/admin/secret/ISaltRotation.java index f01104af..f14552f7 100644 --- a/src/main/java/com/uid2/admin/secret/ISaltRotation.java +++ b/src/main/java/com/uid2/admin/secret/ISaltRotation.java @@ -3,11 +3,13 @@ import com.uid2.shared.store.salt.RotatingSaltProvider; import java.time.Duration; +import java.time.LocalDate; public interface ISaltRotation { Result rotateSalts(RotatingSaltProvider.SaltSnapshot lastSnapshot, Duration[] minAges, - double fraction) throws Exception; + double fraction, + LocalDate nextEffective) throws Exception; class Result { private RotatingSaltProvider.SaltSnapshot snapshot; // can be null if new snapshot is not needed diff --git a/src/main/java/com/uid2/admin/secret/SaltRotation.java b/src/main/java/com/uid2/admin/secret/SaltRotation.java index 6a92f18b..35c3b96e 100644 --- a/src/main/java/com/uid2/admin/secret/SaltRotation.java +++ b/src/main/java/com/uid2/admin/secret/SaltRotation.java @@ -3,108 +3,126 @@ import com.uid2.shared.model.SaltEntry; import com.uid2.shared.secret.IKeyGenerator; import com.uid2.shared.store.salt.RotatingSaltProvider; -import io.vertx.core.json.JsonObject; + +import com.uid2.shared.store.salt.RotatingSaltProvider.SaltSnapshot; import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.IntStream; import static java.util.stream.Collectors.toList; public class SaltRotation implements ISaltRotation { - private static final String SNAPSHOT_ACTIVATES_IN_SECONDS = "salt_snapshot_activates_in_seconds"; - private static final String SNAPSHOT_EXPIRES_AFTER_SECONDS = "salt_snapshot_expires_after_seconds"; - private final IKeyGenerator keyGenerator; - private final Duration snapshotActivatesIn; - private final Duration snapshotExpiresAfter; - - public static Duration getSnapshotActivatesIn(JsonObject config) { - return Duration.ofSeconds(config.getInteger(SNAPSHOT_ACTIVATES_IN_SECONDS)); - } - public static Duration getSnapshotExpiresAfter(JsonObject config) { - return Duration.ofSeconds(config.getInteger(SNAPSHOT_EXPIRES_AFTER_SECONDS)); - } - public SaltRotation(JsonObject config, IKeyGenerator keyGenerator) { + public SaltRotation(IKeyGenerator keyGenerator) { this.keyGenerator = keyGenerator; + } - snapshotActivatesIn = getSnapshotActivatesIn(config); - snapshotExpiresAfter = getSnapshotExpiresAfter(config); + @Override + public Result rotateSalts(RotatingSaltProvider.SaltSnapshot lastSnapshot, + Duration[] minAges, + double fraction, + LocalDate targetDate) throws Exception { + + final Instant nextEffective = targetDate.atStartOfDay().toInstant(ZoneOffset.UTC); + final Instant nextExpires = nextEffective.plus(7, ChronoUnit.DAYS); + if (nextEffective.equals(lastSnapshot.getEffective()) || nextEffective.isBefore(lastSnapshot.getEffective())) { + return Result.noSnapshot("cannot create a new salt snapshot with effective timestamp equal or prior to that of an existing snapshot"); + } - if (snapshotActivatesIn.compareTo(snapshotExpiresAfter) >= 0) { - throw new IllegalStateException(SNAPSHOT_EXPIRES_AFTER_SECONDS + " must be greater than " + SNAPSHOT_ACTIVATES_IN_SECONDS); + List saltIndexesToRotate = pickSaltIndexesToRotate(lastSnapshot, nextEffective, minAges, fraction); + if (saltIndexesToRotate.isEmpty()) { + return Result.noSnapshot("all salts are below min rotation age"); } + + var updatedSalts = updateSalts(lastSnapshot.getAllRotatingSalts(), saltIndexesToRotate, nextEffective.toEpochMilli()); + + SaltSnapshot nextSnapshot = new SaltSnapshot( + nextEffective, + nextExpires, + updatedSalts, + lastSnapshot.getFirstLevelSalt()); + return Result.fromSnapshot(nextSnapshot); } - @Override - public Result rotateSalts(RotatingSaltProvider.SaltSnapshot lastSnapshot, - Duration[] minAges, - double fraction) throws Exception { - final Instant now = Instant.now(); - final Instant nextEffective = now.plusSeconds(snapshotActivatesIn.getSeconds()); - final Instant nextExpires = nextEffective.plusSeconds(snapshotExpiresAfter.getSeconds()); - if (!nextEffective.isAfter(lastSnapshot.getEffective())) { - return Result.noSnapshot("cannot create a new salt snapshot with effective timestamp prior to that of an existing snapshot"); + private SaltEntry[] updateSalts(SaltEntry[] oldSalts, List saltIndexesToRotate, long nextEffective) throws Exception { + var updatedSalts = new SaltEntry[oldSalts.length]; + + for (int i = 0; i < oldSalts.length; i++) { + var shouldRotate = saltIndexesToRotate.contains(i); + updatedSalts[i] = updateSalt(oldSalts[i], shouldRotate, nextEffective); } + return updatedSalts; + } + + private SaltEntry updateSalt(SaltEntry oldSalt, boolean shouldRotate, long nextEffective) throws Exception { + var currentSalt = shouldRotate ? this.keyGenerator.generateRandomKeyString(32) : oldSalt.currentSalt(); + var lastUpdated = shouldRotate ? nextEffective : oldSalt.lastUpdated(); + + return new SaltEntry( + oldSalt.id(), + oldSalt.hashedId(), + lastUpdated, + currentSalt, + null, + null, + null, + null + ); + } + private List pickSaltIndexesToRotate( + SaltSnapshot lastSnapshot, + Instant nextEffective, + Duration[] minAges, + double fraction) { final Instant[] thresholds = Arrays.stream(minAges) - .map(a -> now.minusSeconds(a.getSeconds())) + .map(age -> nextEffective.minusSeconds(age.getSeconds())) .sorted() .toArray(Instant[]::new); - final int maxSalts = (int)Math.ceil(lastSnapshot.getAllRotatingSalts().length * fraction); - final List entryIndexes = new ArrayList<>(); + final int maxSalts = (int) Math.ceil(lastSnapshot.getAllRotatingSalts().length * fraction); + final List indexesToRotate = new ArrayList<>(); Instant minLastUpdated = Instant.ofEpochMilli(0); for (Instant threshold : thresholds) { - if (entryIndexes.size() >= maxSalts) break; - addIndexesToRotate(entryIndexes, lastSnapshot, - minLastUpdated.toEpochMilli(), threshold.toEpochMilli(), - maxSalts - entryIndexes.size()); + if (indexesToRotate.size() >= maxSalts) break; + addIndexesToRotate( + indexesToRotate, + lastSnapshot, + minLastUpdated.toEpochMilli(), + threshold.toEpochMilli(), + maxSalts - indexesToRotate.size() + ); minLastUpdated = threshold; } - - if (entryIndexes.isEmpty()) return Result.noSnapshot("all salts are below min rotation age"); - - return Result.fromSnapshot(createRotatedSnapshot(lastSnapshot, nextEffective, nextExpires, entryIndexes)); + return indexesToRotate; } private void addIndexesToRotate(List entryIndexes, - RotatingSaltProvider.SaltSnapshot lastSnapshot, + SaltSnapshot lastSnapshot, long minLastUpdated, long maxLastUpdated, int maxIndexes) { final SaltEntry[] entries = lastSnapshot.getAllRotatingSalts(); final List candidateIndexes = IntStream.range(0, entries.length) .filter(i -> isBetween(entries[i].lastUpdated(), minLastUpdated, maxLastUpdated)) - .boxed().collect(toList()); + .boxed() + .collect(toList()); if (candidateIndexes.size() <= maxIndexes) { entryIndexes.addAll(candidateIndexes); return; } Collections.shuffle(candidateIndexes); - candidateIndexes.stream().limit(maxIndexes).forEachOrdered(i -> entryIndexes.add(i)); + candidateIndexes.stream().limit(maxIndexes).forEachOrdered(entryIndexes::add); } private static boolean isBetween(long t, long minInclusive, long maxExclusive) { return minInclusive <= t && t < maxExclusive; } - private RotatingSaltProvider.SaltSnapshot createRotatedSnapshot(RotatingSaltProvider.SaltSnapshot lastSnapshot, - Instant nextEffective, - Instant nextExpires, - List entryIndexes) throws Exception { - final long lastUpdated = nextEffective.toEpochMilli(); - final RotatingSaltProvider.SaltSnapshot nextSnapshot = new RotatingSaltProvider.SaltSnapshot( - nextEffective, nextExpires, - Arrays.copyOf(lastSnapshot.getAllRotatingSalts(), lastSnapshot.getAllRotatingSalts().length), - lastSnapshot.getFirstLevelSalt()); - for (Integer i : entryIndexes) { - final SaltEntry oldSalt = nextSnapshot.getAllRotatingSalts()[i]; - final String secret = this.keyGenerator.generateRandomKeyString(32); - nextSnapshot.getAllRotatingSalts()[i] = new SaltEntry(oldSalt.id(), oldSalt.hashedId(), lastUpdated, secret, null, null, null, null); - } - return nextSnapshot; - } } diff --git a/src/main/java/com/uid2/admin/vertx/RequestUtil.java b/src/main/java/com/uid2/admin/vertx/RequestUtil.java index 1b141186..6ff39f76 100644 --- a/src/main/java/com/uid2/admin/vertx/RequestUtil.java +++ b/src/main/java/com/uid2/admin/vertx/RequestUtil.java @@ -8,7 +8,8 @@ import com.uid2.shared.store.ISiteStore; import io.vertx.ext.web.RoutingContext; -import java.time.Duration; +import java.time.*; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; @@ -229,4 +230,17 @@ public static Optional getDouble(RoutingContext rc, String paramName) { return Optional.empty(); } } + + public static Optional getDate(RoutingContext rc, String paramName, DateTimeFormatter formatter) { + final List values = rc.queryParam(paramName); + if (values.isEmpty()) { + return Optional.empty(); + } + try { + return Optional.of(LocalDate.parse(values.get(0), formatter)); + } catch (Exception ex) { + ResponseUtil.error(rc, 400, "failed to parse " + paramName + ": " + ex.getMessage()); + return Optional.empty(); + } + } } diff --git a/src/main/java/com/uid2/admin/vertx/service/SaltService.java b/src/main/java/com/uid2/admin/vertx/service/SaltService.java index 81196331..086bbc26 100644 --- a/src/main/java/com/uid2/admin/vertx/service/SaltService.java +++ b/src/main/java/com/uid2/admin/vertx/service/SaltService.java @@ -17,7 +17,8 @@ import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; -import java.time.Duration; +import java.time.*; +import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -76,6 +77,8 @@ private void handleSaltRotate(RoutingContext rc) { if (!fraction.isPresent()) return; final Duration[] minAges = RequestUtil.getDurations(rc, "min_ages_in_seconds"); if (minAges == null) return; + final LocalDate targetDate = RequestUtil.getDate(rc, "target_date", DateTimeFormatter.ISO_LOCAL_DATE) + .orElse(LocalDate.now(Clock.systemUTC()).plusDays(1)); // force refresh this.saltProvider.loadContent(); @@ -85,8 +88,9 @@ private void handleSaltRotate(RoutingContext rc) { final List snapshots = this.saltProvider.getSnapshots(); final RotatingSaltProvider.SaltSnapshot lastSnapshot = snapshots.get(snapshots.size() - 1); + final ISaltRotation.Result result = saltRotation.rotateSalts( - lastSnapshot, minAges, fraction.get()); + lastSnapshot, minAges, fraction.get(), targetDate); if (!result.hasSnapshot()) { ResponseUtil.error(rc, 200, result.getReason()); return; diff --git a/src/main/resources/localstack/s3/core/salts/metadata.json b/src/main/resources/localstack/s3/core/salts/metadata.json index dfdc3568..86fd70e8 100644 --- a/src/main/resources/localstack/s3/core/salts/metadata.json +++ b/src/main/resources/localstack/s3/core/salts/metadata.json @@ -7,7 +7,7 @@ "salts" : [ { "effective" : 1670796729291, - "expires" : 1745907348982, + "expires" : 1766125493000, "location" : "salts/salts.txt.1670796729291", "size" : 2 },{ diff --git a/src/test/java/com/uid2/admin/secret/SaltRotationTest.java b/src/test/java/com/uid2/admin/secret/SaltRotationTest.java index fabdbeb8..2bcf4977 100644 --- a/src/test/java/com/uid2/admin/secret/SaltRotationTest.java +++ b/src/test/java/com/uid2/admin/secret/SaltRotationTest.java @@ -3,14 +3,13 @@ import com.uid2.shared.model.SaltEntry; import com.uid2.shared.secret.IKeyGenerator; import com.uid2.shared.store.salt.RotatingSaltProvider; -import io.vertx.core.json.JsonObject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import java.time.Duration; -import java.time.Instant; +import java.time.*; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -20,22 +19,17 @@ import static org.mockito.Mockito.*; public class SaltRotationTest { - private static final int ACTIVATES_IN_SECONDS = 3600; - private static final int EXPIRES_IN_SECONDS = 7200; - - private AutoCloseable mocks; @Mock private IKeyGenerator keyGenerator; private SaltRotation saltRotation; - @BeforeEach - void setup() throws Exception { - mocks = MockitoAnnotations.openMocks(this); + private final LocalDate targetDate = LocalDate.of(2025, 1, 1); + private final Instant targetDateAsInstant = targetDate.atStartOfDay().toInstant(ZoneOffset.UTC); - JsonObject config = new JsonObject(); - config.put("salt_snapshot_activates_in_seconds", ACTIVATES_IN_SECONDS); - config.put("salt_snapshot_expires_after_seconds", EXPIRES_IN_SECONDS); + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); - saltRotation = new SaltRotation(config, keyGenerator); + saltRotation = new SaltRotation(keyGenerator); } private static class SnapshotBuilder @@ -55,7 +49,7 @@ public SnapshotBuilder withEntries(int count, Instant lastUpdated) { public RotatingSaltProvider.SaltSnapshot build(Instant effective, Instant expires) { return new RotatingSaltProvider.SaltSnapshot( - effective, expires, entries.stream().toArray(SaltEntry[]::new), "test_first_level_salt"); + effective, expires, entries.toArray(SaltEntry[]::new), "test_first_level_salt"); } } @@ -71,32 +65,33 @@ private static void assertEqualsClose(Instant expected, Instant actual, int with @Test void rotateSaltsLastSnapshotIsUpToDate() throws Exception { final Duration[] minAges = { - Duration.ofSeconds(100), - Duration.ofSeconds(200), + Duration.ofDays(1), + Duration.ofDays(2), }; - final RotatingSaltProvider.SaltSnapshot lastSnapshot = SnapshotBuilder.start() - .withEntries(10, Instant.ofEpochSecond(10001)) - .build(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS + 10), - Instant.now().plusSeconds(ACTIVATES_IN_SECONDS + EXPIRES_IN_SECONDS + 10)); - - final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2); - assertFalse(result.hasSnapshot()); - verify(keyGenerator, times(0)).generateRandomKeyString(anyInt()); + .withEntries(10, targetDateAsInstant) + .build(targetDateAsInstant, + targetDateAsInstant.plus(7, ChronoUnit.DAYS)); + + final ISaltRotation.Result result1 = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2, targetDate); + assertFalse(result1.hasSnapshot()); + final ISaltRotation.Result result2 = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2, targetDate.minusDays(1)); + assertFalse(result2.hasSnapshot()); } @Test void rotateSaltsAllSaltsUpToDate() throws Exception { final Duration[] minAges = { - Duration.ofSeconds(100), - Duration.ofSeconds(200), + Duration.ofDays(1), + Duration.ofDays(2), }; final RotatingSaltProvider.SaltSnapshot lastSnapshot = SnapshotBuilder.start() - .withEntries(10, Instant.now()) - .build(Instant.now(), Instant.now()); + .withEntries(10, targetDateAsInstant) + .build(targetDateAsInstant.minus(1, ChronoUnit.DAYS), + targetDateAsInstant.plus(6, ChronoUnit.DAYS)); - final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2); + final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2, targetDate); assertFalse(result.hasSnapshot()); verify(keyGenerator, times(0)).generateRandomKeyString(anyInt()); } @@ -104,126 +99,131 @@ void rotateSaltsAllSaltsUpToDate() throws Exception { @Test void rotateSaltsAllSaltsOld() throws Exception { final Duration[] minAges = { - Duration.ofSeconds(100), - Duration.ofSeconds(200), + Duration.ofDays(1), + Duration.ofDays(2), }; - final Instant lastUpdated1 = Instant.now().minusSeconds(500); + final Instant entryLastUpdated = targetDateAsInstant.minus(10, ChronoUnit.DAYS); final RotatingSaltProvider.SaltSnapshot lastSnapshot = SnapshotBuilder.start() - .withEntries(10, lastUpdated1) - .build(Instant.now(), Instant.now()); + .withEntries(10, entryLastUpdated) + .build(targetDateAsInstant.minus(1, ChronoUnit.DAYS), + targetDateAsInstant.plus(6, ChronoUnit.DAYS)); - final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2); + final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2, targetDate); assertTrue(result.hasSnapshot()); assertEquals(2, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), result.getSnapshot().getEffective())); - assertEquals(8, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated1)); - assertEqualsClose(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS), result.getSnapshot().getEffective(), 10); - assertEqualsClose(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS+EXPIRES_IN_SECONDS), result.getSnapshot().getExpires(), 10); + assertEquals(8, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), entryLastUpdated)); + assertEquals(targetDateAsInstant, result.getSnapshot().getEffective()); + assertEquals(targetDateAsInstant.plus(7, ChronoUnit.DAYS), result.getSnapshot().getExpires()); verify(keyGenerator, times(2)).generateRandomKeyString(anyInt()); } @Test void rotateSaltsRotateSaltsFromOldestBucketOnly() throws Exception { final Duration[] minAges = { - Duration.ofSeconds(100), - Duration.ofSeconds(200), + Duration.ofDays(5), + Duration.ofDays(4), }; - final Instant lastUpdated1 = Instant.now().minusSeconds(500); - final Instant lastUpdated2 = Instant.now().minusSeconds(150); - final Instant lastUpdated3 = Instant.now().minusSeconds(50); + final Instant lastUpdated1 = targetDateAsInstant.minus(6, ChronoUnit.DAYS); + final Instant lastUpdated2 = targetDateAsInstant.minus(5, ChronoUnit.DAYS); + final Instant lastUpdated3 = targetDateAsInstant.minus(4, ChronoUnit.DAYS); final RotatingSaltProvider.SaltSnapshot lastSnapshot = SnapshotBuilder.start() .withEntries(3, lastUpdated1) .withEntries(5, lastUpdated2) .withEntries(2, lastUpdated3) - .build(Instant.now(), Instant.now()); + .build(targetDateAsInstant.minus(1, ChronoUnit.DAYS), + targetDateAsInstant.plus(6, ChronoUnit.DAYS)); - final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2); + final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2, targetDate); assertTrue(result.hasSnapshot()); assertEquals(2, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), result.getSnapshot().getEffective())); assertEquals(1, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated1)); assertEquals(5, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated2)); assertEquals(2, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated3)); - assertEqualsClose(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS), result.getSnapshot().getEffective(), 10); - assertEqualsClose(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS+EXPIRES_IN_SECONDS), result.getSnapshot().getExpires(), 10); + assertEquals(targetDateAsInstant, result.getSnapshot().getEffective()); + assertEquals(targetDateAsInstant.plus(7, ChronoUnit.DAYS), result.getSnapshot().getExpires()); verify(keyGenerator, times(2)).generateRandomKeyString(anyInt()); } @Test void rotateSaltsRotateSaltsFromNewerBucketOnly() throws Exception { final Duration[] minAges = { - Duration.ofSeconds(100), - Duration.ofSeconds(200), + Duration.ofDays(5), + Duration.ofDays(3), }; - final Instant lastUpdated1 = Instant.now().minusSeconds(150); - final Instant lastUpdated2 = Instant.now().minusSeconds(50); + final Instant lastUpdated1 = targetDateAsInstant.minus(4, ChronoUnit.DAYS); + final Instant lastUpdated2 = targetDateAsInstant.minus(3, ChronoUnit.DAYS); final RotatingSaltProvider.SaltSnapshot lastSnapshot = SnapshotBuilder.start() .withEntries(3, lastUpdated1) .withEntries(7, lastUpdated2) - .build(Instant.now(), Instant.now()); + .build(targetDateAsInstant.minus(1, ChronoUnit.DAYS), + targetDateAsInstant.plus(6, ChronoUnit.DAYS)); - final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2); + final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.2, targetDate); assertTrue(result.hasSnapshot()); assertEquals(2, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), result.getSnapshot().getEffective())); assertEquals(1, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated1)); assertEquals(7, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated2)); - assertEqualsClose(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS), result.getSnapshot().getEffective(), 10); - assertEqualsClose(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS+EXPIRES_IN_SECONDS), result.getSnapshot().getExpires(), 10); + assertEquals(targetDateAsInstant, result.getSnapshot().getEffective()); + assertEquals(targetDateAsInstant.plus(7, ChronoUnit.DAYS), result.getSnapshot().getExpires()); verify(keyGenerator, times(2)).generateRandomKeyString(anyInt()); } @Test - void rotateSaltsRotateSaltsFromBothBuckets() throws Exception { + void rotateSaltsRotateSaltsFromMultipleBuckets() throws Exception { final Duration[] minAges = { - Duration.ofSeconds(100), - Duration.ofSeconds(200), + Duration.ofDays(5), + Duration.ofDays(4), }; - final Instant lastUpdated1 = Instant.now().minusSeconds(500); - final Instant lastUpdated2 = Instant.now().minusSeconds(150); - final Instant lastUpdated3 = Instant.now().minusSeconds(50); + final Instant lastUpdated1 = targetDateAsInstant.minus(6, ChronoUnit.DAYS); + final Instant lastUpdated2 = targetDateAsInstant.minus(5, ChronoUnit.DAYS); + final Instant lastUpdated3 = targetDateAsInstant.minus(4, ChronoUnit.DAYS); final RotatingSaltProvider.SaltSnapshot lastSnapshot = SnapshotBuilder.start() .withEntries(3, lastUpdated1) .withEntries(5, lastUpdated2) .withEntries(2, lastUpdated3) - .build(Instant.now(), Instant.now()); + .build(targetDateAsInstant.minus(1, ChronoUnit.DAYS), + targetDateAsInstant.plus(6, ChronoUnit.DAYS)); - final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.45); + final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.45, targetDate); assertTrue(result.hasSnapshot()); assertEquals(5, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), result.getSnapshot().getEffective())); assertEquals(0, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated1)); assertEquals(3, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated2)); assertEquals(2, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated3)); - assertEqualsClose(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS), result.getSnapshot().getEffective(), 10); - assertEqualsClose(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS+EXPIRES_IN_SECONDS), result.getSnapshot().getExpires(), 10); + assertEquals(targetDateAsInstant, result.getSnapshot().getEffective()); + assertEquals(targetDateAsInstant.plus(7, ChronoUnit.DAYS), result.getSnapshot().getExpires()); verify(keyGenerator, times(5)).generateRandomKeyString(anyInt()); } @Test void rotateSaltsRotateSaltsInsufficientOutdatedSalts() throws Exception { final Duration[] minAges = { - Duration.ofSeconds(100), - Duration.ofSeconds(200), + Duration.ofDays(5), + Duration.ofDays(3), }; - final Instant lastUpdated1 = Instant.now().minusSeconds(500); - final Instant lastUpdated2 = Instant.now().minusSeconds(150); - final Instant lastUpdated3 = Instant.now().minusSeconds(50); + final Instant lastUpdated1 = targetDateAsInstant.minus(5, ChronoUnit.DAYS); + final Instant lastUpdated2 = targetDateAsInstant.minus(4, ChronoUnit.DAYS); + final Instant lastUpdated3 = targetDateAsInstant.minus(2, ChronoUnit.DAYS); final RotatingSaltProvider.SaltSnapshot lastSnapshot = SnapshotBuilder.start() .withEntries(1, lastUpdated1) .withEntries(2, lastUpdated2) .withEntries(7, lastUpdated3) - .build(Instant.now(), Instant.now()); + .build(targetDateAsInstant.minus(1, ChronoUnit.DAYS), + targetDateAsInstant.plus(6, ChronoUnit.DAYS)); - final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.45); + final ISaltRotation.Result result = saltRotation.rotateSalts(lastSnapshot, minAges, 0.45, targetDate); assertTrue(result.hasSnapshot()); assertEquals(3, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), result.getSnapshot().getEffective())); assertEquals(0, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated1)); assertEquals(0, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated2)); assertEquals(7, countEntriesWithLastUpdated(result.getSnapshot().getAllRotatingSalts(), lastUpdated3)); - assertEqualsClose(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS), result.getSnapshot().getEffective(), 10); - assertEqualsClose(Instant.now().plusSeconds(ACTIVATES_IN_SECONDS+EXPIRES_IN_SECONDS), result.getSnapshot().getExpires(), 10); + assertEquals(targetDateAsInstant, result.getSnapshot().getEffective()); + assertEquals(targetDateAsInstant.plus(7, ChronoUnit.DAYS), result.getSnapshot().getExpires()); verify(keyGenerator, times(3)).generateRandomKeyString(anyInt()); } } diff --git a/src/test/java/com/uid2/admin/vertx/SaltServiceTest.java b/src/test/java/com/uid2/admin/vertx/SaltServiceTest.java index 43b63418..087c0dc8 100644 --- a/src/test/java/com/uid2/admin/vertx/SaltServiceTest.java +++ b/src/test/java/com/uid2/admin/vertx/SaltServiceTest.java @@ -14,6 +14,10 @@ import org.mockito.Mock; import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import static org.junit.jupiter.api.Assertions.*; @@ -95,7 +99,7 @@ void rotateSalts(Vertx vertx, VertxTestContext testContext) throws Exception { final RotatingSaltProvider.SaltSnapshot[] addedSnapshots = { makeSnapshot(Instant.ofEpochMilli(10004), Instant.ofEpochMilli(20004), 10), }; - when(saltRotation.rotateSalts(any(), any(), eq(0.2))).thenReturn(ISaltRotation.Result.fromSnapshot(addedSnapshots[0])); + when(saltRotation.rotateSalts(any(), any(), eq(0.2), eq(LocalDate.now().plusDays(1)))).thenReturn(ISaltRotation.Result.fromSnapshot(addedSnapshots[0])); post(vertx, testContext, "api/salt/rotate?min_ages_in_seconds=50,60,70&fraction=0.2", "", response -> { assertEquals(200, response.statusCode()); @@ -117,7 +121,7 @@ void rotateSaltsNoNewSnapshot(Vertx vertx, VertxTestContext testContext) throws }; setSnapshots(snapshots); - when(saltRotation.rotateSalts(any(), any(), eq(0.2))).thenReturn(ISaltRotation.Result.noSnapshot("test")); + when(saltRotation.rotateSalts(any(), any(), eq(0.2), eq(LocalDate.now().plusDays(1)))).thenReturn(ISaltRotation.Result.noSnapshot("test")); post(vertx, testContext, "api/salt/rotate?min_ages_in_seconds=50,60,70&fraction=0.2", "", response -> { assertEquals(200, response.statusCode()); @@ -128,4 +132,28 @@ void rotateSaltsNoNewSnapshot(Vertx vertx, VertxTestContext testContext) throws testContext.completeNow(); }); } + + @Test + void rotateSaltsWitnSpecificTargetDate(Vertx vertx, VertxTestContext testContext) throws Exception { + fakeAuth(Role.SUPER_USER); + LocalDate targetDate = LocalDate.of(2025, 5, 8); + Instant targetDateAsInstant = targetDate.atStartOfDay().toInstant(ZoneOffset.UTC); + final RotatingSaltProvider.SaltSnapshot[] snapshots = { + makeSnapshot(targetDateAsInstant.minus(5, ChronoUnit.DAYS), targetDateAsInstant.minus(4, ChronoUnit.DAYS), 10), + makeSnapshot(targetDateAsInstant.minus(4, ChronoUnit.DAYS), targetDateAsInstant.minus(3, ChronoUnit.DAYS), 10), + makeSnapshot(targetDateAsInstant.minus(3, ChronoUnit.DAYS), targetDateAsInstant.minus(2, ChronoUnit.DAYS), 10), + }; + setSnapshots(snapshots); + + final RotatingSaltProvider.SaltSnapshot[] addedSnapshots = { + makeSnapshot(targetDateAsInstant, targetDateAsInstant.plus(1, ChronoUnit.DAYS), 10), + }; + + when(saltRotation.rotateSalts(any(), any(), eq(0.2), eq(targetDate))).thenReturn(ISaltRotation.Result.fromSnapshot(addedSnapshots[0])); + + post(vertx, testContext, "api/salt/rotate?min_ages_in_seconds=50,60,70&fraction=0.2&target_date=2025-05-08", "", response -> { + assertEquals(200, response.statusCode()); + testContext.completeNow(); + }); + } }