Skip to content

Commit 80e4758

Browse files
Merge pull request #454 from IABTechLab/sch-UID2-5350-rotate-salts-at-utc-midnight
sch-UID2-5350 updated salt rotation to be effective at utc midnight
2 parents 1b4ce47 + 460d890 commit 80e4758

File tree

8 files changed

+206
-140
lines changed

8 files changed

+206
-140
lines changed

src/main/java/com/uid2/admin/Main.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ public void run() {
232232
WriteLock writeLock = new WriteLock();
233233
KeyHasher keyHasher = new KeyHasher();
234234
IKeypairGenerator keypairGenerator = new SecureKeypairGenerator();
235-
ISaltRotation saltRotation = new SaltRotation(config, keyGenerator);
235+
ISaltRotation saltRotation = new SaltRotation(keyGenerator);
236236
EncryptionKeyService encryptionKeyService = new EncryptionKeyService(
237237
config, auth, writeLock, encryptionKeyStoreWriter, keysetKeyStoreWriter, keyProvider, keysetKeysProvider, adminKeysetProvider, adminKeysetStoreWriter, keyGenerator, clock);
238238
KeysetManager keysetManager = new KeysetManager(

src/main/java/com/uid2/admin/secret/ISaltRotation.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import com.uid2.shared.store.salt.RotatingSaltProvider;
44

55
import java.time.Duration;
6+
import java.time.LocalDate;
67

78
public interface ISaltRotation {
89
Result rotateSalts(RotatingSaltProvider.SaltSnapshot lastSnapshot,
910
Duration[] minAges,
10-
double fraction) throws Exception;
11+
double fraction,
12+
LocalDate nextEffective) throws Exception;
1113

1214
class Result {
1315
private RotatingSaltProvider.SaltSnapshot snapshot; // can be null if new snapshot is not needed

src/main/java/com/uid2/admin/secret/SaltRotation.java

Lines changed: 75 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,108 +3,126 @@
33
import com.uid2.shared.model.SaltEntry;
44
import com.uid2.shared.secret.IKeyGenerator;
55
import com.uid2.shared.store.salt.RotatingSaltProvider;
6-
import io.vertx.core.json.JsonObject;
6+
7+
import com.uid2.shared.store.salt.RotatingSaltProvider.SaltSnapshot;
78

89
import java.time.Duration;
910
import java.time.Instant;
11+
import java.time.LocalDate;
12+
import java.time.ZoneOffset;
13+
import java.time.temporal.ChronoUnit;
1014
import java.util.*;
1115
import java.util.stream.IntStream;
1216

1317
import static java.util.stream.Collectors.toList;
1418

1519
public class SaltRotation implements ISaltRotation {
16-
private static final String SNAPSHOT_ACTIVATES_IN_SECONDS = "salt_snapshot_activates_in_seconds";
17-
private static final String SNAPSHOT_EXPIRES_AFTER_SECONDS = "salt_snapshot_expires_after_seconds";
18-
1920
private final IKeyGenerator keyGenerator;
20-
private final Duration snapshotActivatesIn;
21-
private final Duration snapshotExpiresAfter;
22-
23-
public static Duration getSnapshotActivatesIn(JsonObject config) {
24-
return Duration.ofSeconds(config.getInteger(SNAPSHOT_ACTIVATES_IN_SECONDS));
25-
}
26-
public static Duration getSnapshotExpiresAfter(JsonObject config) {
27-
return Duration.ofSeconds(config.getInteger(SNAPSHOT_EXPIRES_AFTER_SECONDS));
28-
}
2921

30-
public SaltRotation(JsonObject config, IKeyGenerator keyGenerator) {
22+
public SaltRotation(IKeyGenerator keyGenerator) {
3123
this.keyGenerator = keyGenerator;
24+
}
3225

33-
snapshotActivatesIn = getSnapshotActivatesIn(config);
34-
snapshotExpiresAfter = getSnapshotExpiresAfter(config);
26+
@Override
27+
public Result rotateSalts(RotatingSaltProvider.SaltSnapshot lastSnapshot,
28+
Duration[] minAges,
29+
double fraction,
30+
LocalDate targetDate) throws Exception {
31+
32+
final Instant nextEffective = targetDate.atStartOfDay().toInstant(ZoneOffset.UTC);
33+
final Instant nextExpires = nextEffective.plus(7, ChronoUnit.DAYS);
34+
if (nextEffective.equals(lastSnapshot.getEffective()) || nextEffective.isBefore(lastSnapshot.getEffective())) {
35+
return Result.noSnapshot("cannot create a new salt snapshot with effective timestamp equal or prior to that of an existing snapshot");
36+
}
3537

36-
if (snapshotActivatesIn.compareTo(snapshotExpiresAfter) >= 0) {
37-
throw new IllegalStateException(SNAPSHOT_EXPIRES_AFTER_SECONDS + " must be greater than " + SNAPSHOT_ACTIVATES_IN_SECONDS);
38+
List<Integer> saltIndexesToRotate = pickSaltIndexesToRotate(lastSnapshot, nextEffective, minAges, fraction);
39+
if (saltIndexesToRotate.isEmpty()) {
40+
return Result.noSnapshot("all salts are below min rotation age");
3841
}
42+
43+
var updatedSalts = updateSalts(lastSnapshot.getAllRotatingSalts(), saltIndexesToRotate, nextEffective.toEpochMilli());
44+
45+
SaltSnapshot nextSnapshot = new SaltSnapshot(
46+
nextEffective,
47+
nextExpires,
48+
updatedSalts,
49+
lastSnapshot.getFirstLevelSalt());
50+
return Result.fromSnapshot(nextSnapshot);
3951
}
4052

41-
@Override
42-
public Result rotateSalts(RotatingSaltProvider.SaltSnapshot lastSnapshot,
43-
Duration[] minAges,
44-
double fraction) throws Exception {
45-
final Instant now = Instant.now();
46-
final Instant nextEffective = now.plusSeconds(snapshotActivatesIn.getSeconds());
47-
final Instant nextExpires = nextEffective.plusSeconds(snapshotExpiresAfter.getSeconds());
48-
if (!nextEffective.isAfter(lastSnapshot.getEffective())) {
49-
return Result.noSnapshot("cannot create a new salt snapshot with effective timestamp prior to that of an existing snapshot");
53+
private SaltEntry[] updateSalts(SaltEntry[] oldSalts, List<Integer> saltIndexesToRotate, long nextEffective) throws Exception {
54+
var updatedSalts = new SaltEntry[oldSalts.length];
55+
56+
for (int i = 0; i < oldSalts.length; i++) {
57+
var shouldRotate = saltIndexesToRotate.contains(i);
58+
updatedSalts[i] = updateSalt(oldSalts[i], shouldRotate, nextEffective);
5059
}
60+
return updatedSalts;
61+
}
62+
63+
private SaltEntry updateSalt(SaltEntry oldSalt, boolean shouldRotate, long nextEffective) throws Exception {
64+
var currentSalt = shouldRotate ? this.keyGenerator.generateRandomKeyString(32) : oldSalt.currentSalt();
65+
var lastUpdated = shouldRotate ? nextEffective : oldSalt.lastUpdated();
66+
67+
return new SaltEntry(
68+
oldSalt.id(),
69+
oldSalt.hashedId(),
70+
lastUpdated,
71+
currentSalt,
72+
null,
73+
null,
74+
null,
75+
null
76+
);
77+
}
5178

79+
private List<Integer> pickSaltIndexesToRotate(
80+
SaltSnapshot lastSnapshot,
81+
Instant nextEffective,
82+
Duration[] minAges,
83+
double fraction) {
5284
final Instant[] thresholds = Arrays.stream(minAges)
53-
.map(a -> now.minusSeconds(a.getSeconds()))
85+
.map(age -> nextEffective.minusSeconds(age.getSeconds()))
5486
.sorted()
5587
.toArray(Instant[]::new);
56-
final int maxSalts = (int)Math.ceil(lastSnapshot.getAllRotatingSalts().length * fraction);
57-
final List<Integer> entryIndexes = new ArrayList<>();
88+
final int maxSalts = (int) Math.ceil(lastSnapshot.getAllRotatingSalts().length * fraction);
89+
final List<Integer> indexesToRotate = new ArrayList<>();
5890

5991
Instant minLastUpdated = Instant.ofEpochMilli(0);
6092
for (Instant threshold : thresholds) {
61-
if (entryIndexes.size() >= maxSalts) break;
62-
addIndexesToRotate(entryIndexes, lastSnapshot,
63-
minLastUpdated.toEpochMilli(), threshold.toEpochMilli(),
64-
maxSalts - entryIndexes.size());
93+
if (indexesToRotate.size() >= maxSalts) break;
94+
addIndexesToRotate(
95+
indexesToRotate,
96+
lastSnapshot,
97+
minLastUpdated.toEpochMilli(),
98+
threshold.toEpochMilli(),
99+
maxSalts - indexesToRotate.size()
100+
);
65101
minLastUpdated = threshold;
66102
}
67-
68-
if (entryIndexes.isEmpty()) return Result.noSnapshot("all salts are below min rotation age");
69-
70-
return Result.fromSnapshot(createRotatedSnapshot(lastSnapshot, nextEffective, nextExpires, entryIndexes));
103+
return indexesToRotate;
71104
}
72105

73106
private void addIndexesToRotate(List<Integer> entryIndexes,
74-
RotatingSaltProvider.SaltSnapshot lastSnapshot,
107+
SaltSnapshot lastSnapshot,
75108
long minLastUpdated,
76109
long maxLastUpdated,
77110
int maxIndexes) {
78111
final SaltEntry[] entries = lastSnapshot.getAllRotatingSalts();
79112
final List<Integer> candidateIndexes = IntStream.range(0, entries.length)
80113
.filter(i -> isBetween(entries[i].lastUpdated(), minLastUpdated, maxLastUpdated))
81-
.boxed().collect(toList());
114+
.boxed()
115+
.collect(toList());
82116
if (candidateIndexes.size() <= maxIndexes) {
83117
entryIndexes.addAll(candidateIndexes);
84118
return;
85119
}
86120
Collections.shuffle(candidateIndexes);
87-
candidateIndexes.stream().limit(maxIndexes).forEachOrdered(i -> entryIndexes.add(i));
121+
candidateIndexes.stream().limit(maxIndexes).forEachOrdered(entryIndexes::add);
88122
}
89123

90124
private static boolean isBetween(long t, long minInclusive, long maxExclusive) {
91125
return minInclusive <= t && t < maxExclusive;
92126
}
93127

94-
private RotatingSaltProvider.SaltSnapshot createRotatedSnapshot(RotatingSaltProvider.SaltSnapshot lastSnapshot,
95-
Instant nextEffective,
96-
Instant nextExpires,
97-
List<Integer> entryIndexes) throws Exception {
98-
final long lastUpdated = nextEffective.toEpochMilli();
99-
final RotatingSaltProvider.SaltSnapshot nextSnapshot = new RotatingSaltProvider.SaltSnapshot(
100-
nextEffective, nextExpires,
101-
Arrays.copyOf(lastSnapshot.getAllRotatingSalts(), lastSnapshot.getAllRotatingSalts().length),
102-
lastSnapshot.getFirstLevelSalt());
103-
for (Integer i : entryIndexes) {
104-
final SaltEntry oldSalt = nextSnapshot.getAllRotatingSalts()[i];
105-
final String secret = this.keyGenerator.generateRandomKeyString(32);
106-
nextSnapshot.getAllRotatingSalts()[i] = new SaltEntry(oldSalt.id(), oldSalt.hashedId(), lastUpdated, secret, null, null, null, null);
107-
}
108-
return nextSnapshot;
109-
}
110128
}

src/main/java/com/uid2/admin/vertx/RequestUtil.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import com.uid2.shared.store.ISiteStore;
99
import io.vertx.ext.web.RoutingContext;
1010

11-
import java.time.Duration;
11+
import java.time.*;
12+
import java.time.format.DateTimeFormatter;
1213
import java.util.*;
1314
import java.util.stream.Collectors;
1415

@@ -229,4 +230,17 @@ public static Optional<Double> getDouble(RoutingContext rc, String paramName) {
229230
return Optional.empty();
230231
}
231232
}
233+
234+
public static Optional<LocalDate> getDate(RoutingContext rc, String paramName, DateTimeFormatter formatter) {
235+
final List<String> values = rc.queryParam(paramName);
236+
if (values.isEmpty()) {
237+
return Optional.empty();
238+
}
239+
try {
240+
return Optional.of(LocalDate.parse(values.get(0), formatter));
241+
} catch (Exception ex) {
242+
ResponseUtil.error(rc, 400, "failed to parse " + paramName + ": " + ex.getMessage());
243+
return Optional.empty();
244+
}
245+
}
232246
}

src/main/java/com/uid2/admin/vertx/service/SaltService.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
import io.vertx.ext.web.Router;
1818
import io.vertx.ext.web.RoutingContext;
1919

20-
import java.time.Duration;
20+
import java.time.*;
21+
import java.time.format.DateTimeFormatter;
2122
import java.util.Arrays;
2223
import java.util.List;
2324
import java.util.Optional;
@@ -76,6 +77,8 @@ private void handleSaltRotate(RoutingContext rc) {
7677
if (!fraction.isPresent()) return;
7778
final Duration[] minAges = RequestUtil.getDurations(rc, "min_ages_in_seconds");
7879
if (minAges == null) return;
80+
final LocalDate targetDate = RequestUtil.getDate(rc, "target_date", DateTimeFormatter.ISO_LOCAL_DATE)
81+
.orElse(LocalDate.now(Clock.systemUTC()).plusDays(1));
7982

8083
// force refresh
8184
this.saltProvider.loadContent();
@@ -85,8 +88,9 @@ private void handleSaltRotate(RoutingContext rc) {
8588

8689
final List<RotatingSaltProvider.SaltSnapshot> snapshots = this.saltProvider.getSnapshots();
8790
final RotatingSaltProvider.SaltSnapshot lastSnapshot = snapshots.get(snapshots.size() - 1);
91+
8892
final ISaltRotation.Result result = saltRotation.rotateSalts(
89-
lastSnapshot, minAges, fraction.get());
93+
lastSnapshot, minAges, fraction.get(), targetDate);
9094
if (!result.hasSnapshot()) {
9195
ResponseUtil.error(rc, 200, result.getReason());
9296
return;

src/main/resources/localstack/s3/core/salts/metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"salts" : [
88
{
99
"effective" : 1670796729291,
10-
"expires" : 1745907348982,
10+
"expires" : 1766125493000,
1111
"location" : "salts/salts.txt.1670796729291",
1212
"size" : 2
1313
},{

0 commit comments

Comments
 (0)