Skip to content

Commit 640de8b

Browse files
committed
Track durable revocation reconciliation
1 parent 9504823 commit 640de8b

2 files changed

Lines changed: 91 additions & 8 deletions

File tree

SimpleAPI/src/main/java/com/bencodez/simpleapi/servercomm/http/HttpEnrollmentAuthority.java

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,20 @@ public final class HttpEnrollmentAuthority {
3333
private static final Duration MIN_RENEWAL_INTERVAL = Duration.ofMinutes(1);
3434
private static final int MAX_PENDING_ENROLLMENTS = 128;
3535
private static final int MAX_BINDINGS = 128;
36+
private static final int MAX_REVOCATION_MARKERS = MAX_BINDINGS + MAX_PENDING_ENROLLMENTS;
3637
private static final long MAX_STATE_BYTES = 65536;
3738
private final HttpTlsIdentity identity;
3839
private final Clock clock;
3940
private final Path stateFile;
4041
private final Map<String, Enrollment> enrollments = new HashMap<>();
4142
private final Map<String, ClientBinding> bindings = new HashMap<>();
4243
private final Map<String, Instant> renewalNotBefore = new HashMap<>();
44+
private final Map<String, String> revocationMarkers = new HashMap<>();
4345
private boolean persistenceFailure;
4446
private boolean rollbackStateAvailable;
4547
private boolean revocationRetryRequired;
4648
private String revocationRetryServerId;
49+
private String revocationRetryFingerprint;
4750

4851
/** Creates a restart-safe authority. State contains public certificate pins plus bounded hashes of pending tokens. */
4952
public HttpEnrollmentAuthority(HttpTlsIdentity identity, Path stateDirectory) throws java.io.IOException {
@@ -247,13 +250,19 @@ private void revokeLocked(String serverId) {
247250
// Absence is the durable revocation fence: authentication always requires an exact active binding.
248251
ClientBinding removedBinding = bindings.remove(serverId);
249252
Instant removedRenewalNotBefore = renewalNotBefore.remove(serverId);
253+
String revocationFingerprint = revocationFingerprint(serverId, removedBinding,
254+
removedEnrollments, removedRenewalNotBefore);
255+
Map<String, String> previousRevocationMarkers = new HashMap<>(revocationMarkers);
250256
if (removedBinding != null || !removedEnrollments.isEmpty() || removedRenewalNotBefore != null
251257
|| revocationRetryRequired) try {
258+
revocationMarkers.put(serverId, revocationFingerprint);
259+
trimRevocationMarkers(serverId);
252260
persistState();
253261
persistenceFailure = false;
254262
rollbackStateAvailable = false;
255263
revocationRetryRequired = false;
256264
revocationRetryServerId = null;
265+
revocationRetryFingerprint = null;
257266
}
258267
catch (java.io.IOException failure) {
259268
// Before publication, restore the exact disk-backed state so a retry still has work to persist.
@@ -262,12 +271,15 @@ private void revokeLocked(String serverId) {
262271
if (removedBinding != null) bindings.put(serverId, removedBinding);
263272
enrollments.putAll(removedEnrollments);
264273
if (removedRenewalNotBefore != null) renewalNotBefore.put(serverId, removedRenewalNotBefore);
274+
revocationMarkers.clear();
275+
revocationMarkers.putAll(previousRevocationMarkers);
265276
rollbackStateAvailable = true;
266277
}
267278
persistenceFailure = true;
268279
if (failure instanceof DurableFiles.PublishedException) rollbackStateAvailable = false;
269280
revocationRetryRequired = true;
270281
revocationRetryServerId = serverId;
282+
revocationRetryFingerprint = revocationFingerprint;
271283
throw new IllegalStateException("Could not persist HTTP certificate revocation", failure);
272284
}
273285
}
@@ -323,14 +335,17 @@ private void refreshState(boolean allowDirectoryFailure) throws java.io.IOExcept
323335
Map<String, Enrollment> previousEnrollments = new HashMap<>(enrollments);
324336
Map<String, ClientBinding> previousBindings = new HashMap<>(bindings);
325337
Map<String, Instant> previousRenewalNotBefore = new HashMap<>(renewalNotBefore);
338+
Map<String, String> previousRevocationMarkers = new HashMap<>(revocationMarkers);
326339
enrollments.clear();
327340
bindings.clear();
328341
renewalNotBefore.clear();
342+
revocationMarkers.clear();
329343
try { loadState(); }
330344
catch (java.io.IOException failure) {
331345
enrollments.putAll(previousEnrollments);
332346
bindings.putAll(previousBindings);
333347
renewalNotBefore.putAll(previousRenewalNotBefore);
348+
revocationMarkers.putAll(previousRevocationMarkers);
334349
throw failure;
335350
}
336351
boolean peerCompletedRevocation = persistenceFailure && rollbackStateAvailable
@@ -346,16 +361,57 @@ private void refreshState(boolean allowDirectoryFailure) throws java.io.IOExcept
346361
rollbackStateAvailable = false;
347362
revocationRetryRequired = false;
348363
revocationRetryServerId = null;
364+
revocationRetryFingerprint = null;
349365
}
350366
}
351367

352368
private boolean revocationReflectedInState() {
353-
if (revocationRetryServerId == null || bindings.containsKey(revocationRetryServerId)
369+
if (revocationRetryServerId == null) return false;
370+
if (revocationRetryFingerprint != null && revocationRetryFingerprint.equals(
371+
revocationMarkers.get(revocationRetryServerId))) return true;
372+
if (bindings.containsKey(revocationRetryServerId)
354373
|| renewalNotBefore.containsKey(revocationRetryServerId)) return false;
355374
return enrollments.values().stream()
356375
.noneMatch(enrollment -> revocationRetryServerId.equals(enrollment.serverId()));
357376
}
358377

378+
private static String revocationFingerprint(String serverId, ClientBinding binding,
379+
Map<String, Enrollment> removedEnrollments, Instant renewal) {
380+
StringBuilder value = new StringBuilder();
381+
appendFingerprintPart(value, serverId);
382+
appendFingerprintPart(value, binding == null ? null : binding.certificatePin());
383+
appendFingerprintPart(value, binding == null ? null : binding.pendingCertificatePin());
384+
appendFingerprintPart(value, binding == null ? null : Boolean.toString(binding.revoked()));
385+
new java.util.TreeMap<>(removedEnrollments).forEach((lookup, enrollment) -> {
386+
appendFingerprintPart(value, lookup);
387+
appendFingerprintPart(value, Long.toString(enrollment.expiresAt().toEpochMilli()));
388+
appendFingerprintPart(value, enrollment.serverId());
389+
appendFingerprintPart(value, enrollment.pendingCertificatePin());
390+
});
391+
appendFingerprintPart(value, renewal == null ? null : Long.toString(renewal.toEpochMilli()));
392+
return HttpTransportSecrets.sha256Hex(value.toString().getBytes(StandardCharsets.UTF_8));
393+
}
394+
395+
private static void appendFingerprintPart(StringBuilder output, String value) {
396+
if (value == null) output.append("-1:");
397+
else output.append(value.length()).append(':').append(value);
398+
}
399+
400+
private void trimRevocationMarkers(String preservedServerId) throws java.io.IOException {
401+
var iterator = revocationMarkers.keySet().iterator();
402+
while (revocationMarkers.size() > MAX_REVOCATION_MARKERS && iterator.hasNext()) {
403+
String candidate = iterator.next();
404+
if (!candidate.equals(preservedServerId) && !serverStatePresent(candidate)) iterator.remove();
405+
}
406+
if (revocationMarkers.size() > MAX_REVOCATION_MARKERS)
407+
throw new java.io.IOException("HTTP enrollment revocation history exceeds its bound");
408+
}
409+
410+
private boolean serverStatePresent(String serverId) {
411+
return bindings.containsKey(serverId) || renewalNotBefore.containsKey(serverId)
412+
|| enrollments.values().stream().anyMatch(enrollment -> serverId.equals(enrollment.serverId()));
413+
}
414+
359415
private synchronized void loadState() throws java.io.IOException {
360416
if (stateFile == null || !Files.exists(stateFile, LinkOption.NOFOLLOW_LINKS)) return;
361417
if (!Files.isRegularFile(stateFile, LinkOption.NOFOLLOW_LINKS) || Files.size(stateFile) > MAX_STATE_BYTES)
@@ -365,7 +421,7 @@ private synchronized void loadState() throws java.io.IOException {
365421
try (var input = Files.newInputStream(stateFile, LinkOption.NOFOLLOW_LINKS)) { properties.load(input); }
366422
String version = properties.getProperty("version");
367423
if (!("1".equals(version) || "2".equals(version) || "3".equals(version) || "4".equals(version)
368-
|| "5".equals(version)))
424+
|| "5".equals(version) || "6".equals(version)))
369425
throw new java.io.IOException("HTTP enrollment state is invalid");
370426
for (String key : properties.stringPropertyNames()) {
371427
if (key.startsWith("binding.")) {
@@ -374,7 +430,7 @@ private synchronized void loadState() throws java.io.IOException {
374430
String[] value = properties.getProperty(key, "").split(":", -1);
375431
if (!((value.length == 2 && "1".equals(version))
376432
|| (value.length == 3 && ("2".equals(version) || "3".equals(version)
377-
|| "4".equals(version) || "5".equals(version))))
433+
|| "4".equals(version) || "5".equals(version) || "6".equals(version))))
378434
|| !value[0].matches("[0-9a-f]{64}"))
379435
throw new java.io.IOException("HTTP enrollment state is invalid");
380436
String pending = value.length == 3 && !"-".equals(value[1]) ? value[1] : null;
@@ -386,7 +442,8 @@ private synchronized void loadState() throws java.io.IOException {
386442
bindings.put(serverId, new ClientBinding(value[0], pending, false));
387443
}
388444
} else if (key.startsWith("enrollment.")
389-
&& ("3".equals(version) || "4".equals(version) || "5".equals(version))) {
445+
&& ("3".equals(version) || "4".equals(version) || "5".equals(version)
446+
|| "6".equals(version))) {
390447
String lookup = key.substring("enrollment.".length());
391448
if (!lookup.matches("[A-Za-z0-9_-]{43}")) throw new java.io.IOException("HTTP enrollment state is invalid");
392449
byte[] tokenHash;
@@ -395,7 +452,8 @@ private synchronized void loadState() throws java.io.IOException {
395452
if (tokenHash.length != 32) throw new java.io.IOException("HTTP enrollment state is invalid");
396453
String[] value = properties.getProperty(key, "").split(":", -1);
397454
if (!(value.length == 2 && "3".equals(version))
398-
&& !(value.length == 3 && ("4".equals(version) || "5".equals(version))))
455+
&& !(value.length == 3 && ("4".equals(version) || "5".equals(version)
456+
|| "6".equals(version))))
399457
throw new java.io.IOException("HTTP enrollment state is invalid");
400458
Instant expiresAt;
401459
String serverId;
@@ -411,7 +469,7 @@ private synchronized void loadState() throws java.io.IOException {
411469
throw new java.io.IOException("HTTP enrollment state exceeds its bound");
412470
enrollments.put(lookup, new Enrollment(tokenHash, expiresAt, serverId, pendingPin));
413471
}
414-
} else if (key.startsWith("renewal.") && "5".equals(version)) {
472+
} else if (key.startsWith("renewal.") && ("5".equals(version) || "6".equals(version))) {
415473
String encodedServer = key.substring("renewal.".length());
416474
String serverId;
417475
Instant notBefore;
@@ -430,6 +488,22 @@ private synchronized void loadState() throws java.io.IOException {
430488
if (renewalNotBefore.size() >= MAX_BINDINGS
431489
|| renewalNotBefore.putIfAbsent(serverId, notBefore) != null)
432490
throw new java.io.IOException("HTTP enrollment state exceeds its bound");
491+
} else if (key.startsWith("revocation.") && "6".equals(version)) {
492+
String encodedServer = key.substring("revocation.".length());
493+
String serverId;
494+
try {
495+
serverId = HttpTlsIdentity.canonicalServerId(new String(
496+
Base64.getUrlDecoder().decode(encodedServer), StandardCharsets.UTF_8));
497+
String canonicalEncoding = Base64.getUrlEncoder().withoutPadding().encodeToString(
498+
serverId.getBytes(StandardCharsets.UTF_8));
499+
if (!canonicalEncoding.equals(encodedServer)) throw new IllegalArgumentException();
500+
} catch (RuntimeException invalid) {
501+
throw new java.io.IOException("HTTP enrollment state is invalid", invalid);
502+
}
503+
String fingerprint = properties.getProperty(key, "");
504+
if (!fingerprint.matches("[0-9a-f]{64}") || revocationMarkers.size() >= MAX_REVOCATION_MARKERS
505+
|| revocationMarkers.putIfAbsent(serverId, fingerprint) != null)
506+
throw new java.io.IOException("HTTP enrollment state exceeds its bound");
433507
} else if (!"version".equals(key)) throw new java.io.IOException("HTTP enrollment state is invalid");
434508
}
435509
Instant now = clock.instant();
@@ -444,10 +518,11 @@ private synchronized void persistState() throws java.io.IOException {
444518
Instant now = clock.instant();
445519
renewalNotBefore.entrySet().removeIf(entry -> !bindings.containsKey(entry.getKey())
446520
|| !entry.getValue().isAfter(now));
447-
if (reservedBindingCount() > MAX_BINDINGS || enrollments.size() > MAX_PENDING_ENROLLMENTS)
521+
if (reservedBindingCount() > MAX_BINDINGS || enrollments.size() > MAX_PENDING_ENROLLMENTS
522+
|| revocationMarkers.size() > MAX_REVOCATION_MARKERS)
448523
throw new java.io.IOException("HTTP enrollment state exceeds its bound");
449524
Properties properties = new Properties();
450-
properties.setProperty("version", "5");
525+
properties.setProperty("version", "6");
451526
for (Map.Entry<String, ClientBinding> entry : bindings.entrySet()) {
452527
String key = Base64.getUrlEncoder().withoutPadding().encodeToString(entry.getKey().getBytes(StandardCharsets.UTF_8));
453528
properties.setProperty("binding." + key, entry.getValue().certificatePin() + ":"
@@ -466,6 +541,11 @@ private synchronized void persistState() throws java.io.IOException {
466541
entry.getKey().getBytes(StandardCharsets.UTF_8));
467542
properties.setProperty("renewal." + server, Long.toString(entry.getValue().toEpochMilli()));
468543
}
544+
for (Map.Entry<String, String> entry : revocationMarkers.entrySet()) {
545+
String key = Base64.getUrlEncoder().withoutPadding().encodeToString(
546+
entry.getKey().getBytes(StandardCharsets.UTF_8));
547+
properties.setProperty("revocation." + key, entry.getValue());
548+
}
469549
java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
470550
properties.store(bytes, "VotingPlugin HTTP transport authority state");
471551
if (bytes.size() > MAX_STATE_BYTES) throw new java.io.IOException("HTTP enrollment state exceeds its byte bound");

SimpleAPI/src/test/java/com/bencodez/simpleapi/servercomm/http/HttpEnrollmentAuthorityOwnershipTest.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,12 @@ void authorityClearsPrePublicationRevocationFailureCompletedByPeer() throws Exce
8383
"the failed authority must remain fail-closed before peer reconciliation");
8484

8585
peer.revoke("revoked");
86+
HttpConnectionCode replacementCode = peer.createConnectionCode("revoked", endpoint, Duration.ofMinutes(5));
8687
assertFalse(failed.authenticate("revoked", revoked.certificate()));
8788
assertTrue(failed.authenticate("active", active.certificate()),
8889
"adopting a peer-completed revocation must clear the stale global failure fence");
90+
assertNotNull(failed.enroll("revoked", replacementCode.enrollmentToken()),
91+
"a fresh post-revocation enrollment must not obscure the completed revocation");
8992
assertTrue(Files.isRegularFile(state.resolve("http-transport-clients.properties")));
9093
}
9194
}

0 commit comments

Comments
 (0)