@@ -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" );
0 commit comments