diff --git a/src/main/java/com/uid2/admin/salt/SaltRotation.java b/src/main/java/com/uid2/admin/salt/SaltRotation.java index f9bf42a0..adcdf151 100644 --- a/src/main/java/com/uid2/admin/salt/SaltRotation.java +++ b/src/main/java/com/uid2/admin/salt/SaltRotation.java @@ -4,6 +4,8 @@ import com.uid2.shared.model.SaltEntry; import com.uid2.shared.secret.IKeyGenerator; +import com.uid2.shared.store.salt.ISaltProvider; +import com.uid2.shared.store.salt.ISaltProvider.ISaltSnapshot; import com.uid2.shared.store.salt.RotatingSaltProvider.SaltSnapshot; import io.vertx.core.json.JsonObject; import lombok.Getter; @@ -69,6 +71,27 @@ public Result rotateSalts( return Result.fromSnapshot(nextSnapshot); } + public Result rotateSaltsZero( + ISaltSnapshot effectiveSnapshot, + TargetDate targetDate, + Instant nextEffective + ) throws Exception { + var preRotationSalts = effectiveSnapshot.getAllRotatingSalts(); + var nextExpires = nextEffective.plus(7, ChronoUnit.DAYS); + + var postRotationSalts = rotateSalts(preRotationSalts, List.of(), targetDate); + + LOGGER.info("Zero salt rotation complete target_date={}", targetDate); + + var nextSnapshot = new SaltSnapshot( + nextEffective, + nextExpires, + postRotationSalts, + effectiveSnapshot.getFirstLevelSalt()); + return Result.fromSnapshot(nextSnapshot); + } + + private static int getNumSaltsToRotate(SaltEntry[] preRotationSalts, double fraction) { return (int) Math.ceil(preRotationSalts.length * fraction); } diff --git a/src/main/java/com/uid2/admin/vertx/Endpoints.java b/src/main/java/com/uid2/admin/vertx/Endpoints.java index b7045bb5..c6bf0ab3 100644 --- a/src/main/java/com/uid2/admin/vertx/Endpoints.java +++ b/src/main/java/com/uid2/admin/vertx/Endpoints.java @@ -67,6 +67,7 @@ public enum Endpoints { API_SALT_SNAPSHOTS("/api/salt/snapshots"), API_SALT_ROTATE("/api/salt/rotate"), + API_SALT_ROTATE_ZERO("/api/salt/rotate-zero"), API_SEARCH("/api/search"), 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 3b9cadf6..5a86f670 100644 --- a/src/main/java/com/uid2/admin/vertx/service/SaltService.java +++ b/src/main/java/com/uid2/admin/vertx/service/SaltService.java @@ -26,8 +26,7 @@ import java.util.List; import java.util.Optional; -import static com.uid2.admin.vertx.Endpoints.API_SALT_ROTATE; -import static com.uid2.admin.vertx.Endpoints.API_SALT_SNAPSHOTS; +import static com.uid2.admin.vertx.Endpoints.*; public class SaltService implements IService { private static final Logger LOGGER = LoggerFactory.getLogger(SaltService.class); @@ -60,6 +59,12 @@ public void setupRoutes(Router router) { this.handleSaltRotate(ctx); } }, new AuditParams(List.of("fraction", "min_ages_in_seconds", "target_date"), Collections.emptyList()), Role.SUPER_USER, Role.SECRET_ROTATION)); + + router.post(API_SALT_ROTATE_ZERO.toString()).blockingHandler(auth.handle((ctx) -> { + synchronized (writeLock) { + this.handleSaltRotateZero(ctx); + } + }, new AuditParams(List.of(), Collections.emptyList()), Role.MAINTAINER)); } private void handleSaltSnapshots(RoutingContext rc) { @@ -117,6 +122,37 @@ private void handleSaltRotate(RoutingContext rc) { } } + private void handleSaltRotateZero(RoutingContext rc) { + try { + Instant now = Instant.now(); + + // force refresh + this.saltProvider.loadContent(); + + // mark all the referenced files as ready to archive + storageManager.archiveSaltLocations(); + + // Unlike in regular salt rotation, this should be based on the currently effective snapshot. + // The latest snapshot may be in the future, and we may have changes that shouldn't be activated yet. + var effectiveSnapshot = this.saltProvider.getSnapshot(now); + + var result = saltRotation.rotateSaltsZero(effectiveSnapshot, TargetDate.now(), now); + if (!result.hasSnapshot()) { + ResponseUtil.error(rc, 200, result.getReason()); + return; + } + + storageManager.upload(result.getSnapshot()); + + rc.response() + .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .end(toJson(result.getSnapshot()).encode()); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + rc.fail(500, e); + } + } + private JsonObject toJson(RotatingSaltProvider.SaltSnapshot snapshot) { JsonObject jo = new JsonObject(); jo.put("effective", snapshot.getEffective().toEpochMilli()); diff --git a/src/test/java/com/uid2/admin/salt/SaltRotationTest.java b/src/test/java/com/uid2/admin/salt/SaltRotationTest.java index ae863333..b24b7552 100644 --- a/src/test/java/com/uid2/admin/salt/SaltRotationTest.java +++ b/src/test/java/com/uid2/admin/salt/SaltRotationTest.java @@ -443,4 +443,53 @@ private int countEntriesWithLastUpdated(SaltEntry[] entries, TargetDate lastUpda private int countEntriesWithLastUpdated(SaltEntry[] entries, Instant lastUpdated) { return (int) Arrays.stream(entries).filter(e -> e.lastUpdated() == lastUpdated.toEpochMilli()).count(); } + + @Test + void testRotateSaltsZeroDoesntRotateSaltsButUpdatesRefreshFrom() throws Exception { + var lastSnapshot = SaltSnapshotBuilder.start() + .entries( + SaltBuilder.start().lastUpdated(targetDate().minusDays(75)).refreshFrom(targetDate().minusDays(45)).id(1), + SaltBuilder.start().lastUpdated(targetDate().minusDays(60)).refreshFrom(targetDate()).id(2), + SaltBuilder.start().lastUpdated(targetDate().minusDays(30)).refreshFrom(targetDate()).id(3), + SaltBuilder.start().lastUpdated(targetDate().minusDays(20)).refreshFrom(targetDate().plusDays(10)).id(4) + ) + .effective(daysEarlier(1)) + .expires(daysLater(6)) + .build(); + + var expected = List.of( + SaltBuilder.start().lastUpdated(targetDate().minusDays(75)).refreshFrom(targetDate().plusDays(15)).id(1).build(), + SaltBuilder.start().lastUpdated(targetDate().minusDays(60)).refreshFrom(targetDate().plusDays(30)).id(2).build(), + SaltBuilder.start().lastUpdated(targetDate().minusDays(30)).refreshFrom(targetDate().plusDays(30)).id(3).build(), + SaltBuilder.start().lastUpdated(targetDate().minusDays(20)).refreshFrom(targetDate().plusDays(10)).id(4).build() + ).toArray(); + + var result = saltRotation.rotateSaltsZero(lastSnapshot, targetDate(), targetDate().asInstant()); + assertThat(result.hasSnapshot()).isTrue(); + + // None are rotated, refreshFrom is updated where it is now or in the past - same as regular rotation + assertThat(result.getSnapshot().getAllRotatingSalts()).isEqualTo(expected); + + // Effective now + assertThat(result.getSnapshot().getEffective()).isEqualTo(targetDate().asInstant()); + + // Expires in a week + assertThat(result.getSnapshot().getExpires()).isEqualTo(daysLater(7).asInstant()); + } + + @Test + void testRotateSaltsZeroWorksWhenThereIsFutureSaltFile() throws Exception { + // In regular salt rotations if there is a salt + + var lastSnapshot = SaltSnapshotBuilder.start() + .entries(SaltBuilder.start().lastUpdated(targetDate().minusDays(75))) + .effective(daysLater(10)) + .build(); + + var result = saltRotation.rotateSaltsZero(lastSnapshot, targetDate(), targetDate().asInstant()); + + assertThat(result.hasSnapshot()).isTrue(); + assertThat(result.getSnapshot().getEffective()).isEqualTo(targetDate().asInstant()); + assertThat(result.getSnapshot().getExpires()).isEqualTo(daysLater(7).asInstant()); + } } diff --git a/src/test/java/com/uid2/admin/salt/helper/SaltSnapshotBuilder.java b/src/test/java/com/uid2/admin/salt/helper/SaltSnapshotBuilder.java index ee4d6125..0cbb9ed8 100644 --- a/src/test/java/com/uid2/admin/salt/helper/SaltSnapshotBuilder.java +++ b/src/test/java/com/uid2/admin/salt/helper/SaltSnapshotBuilder.java @@ -27,7 +27,7 @@ public static SaltSnapshotBuilder start() { public SaltSnapshotBuilder entries(int count, TargetDate lastUpdated) { for (int i = 0; i < count; ++i) { - entries.add(SaltBuilder.start().lastUpdated(lastUpdated).build()); + entries.add(SaltBuilder.start().lastUpdated(lastUpdated).refreshFrom(lastUpdated.plusDays(30)).build()); } return this; } diff --git a/webroot/adm/salt.html b/webroot/adm/salt.html index 479b26bf..f578e303 100644 --- a/webroot/adm/salt.html +++ b/webroot/adm/salt.html @@ -13,10 +13,18 @@