33import com .peculiarventures .mtaqr .issuer .Issuer ;
44import com .peculiarventures .mtaqr .signing .Signer ;
55import com .peculiarventures .mtaqr .signing .SignatureVerifier ;
6+ import com .peculiarventures .mtaqr .cascade .Cascade ;
67import com .peculiarventures .mtaqr .trust .TrustConfig ;
78
89import java .net .URI ;
@@ -94,24 +95,27 @@ public record TraceResult(
9495 public static Builder builder () { return new Builder (); }
9596
9697 public static final class Builder {
97- private TrustConfig trust ;
98- private HttpClient httpClient ;
99- private NoteProvider noteProvider ;
98+ private TrustConfig trust ;
99+ private HttpClient httpClient ;
100+ private NoteProvider noteProvider ;
101+ private RevocationProvider revocationProvider ;
100102
101103 public Builder trust (TrustConfig v ) { trust = v ; return this ; }
102104 public Builder httpClient (HttpClient v ) { httpClient = v ; return this ; }
103105 /** Inject a note provider for testing — bypasses HTTP. */
104106 public Builder noteProvider (NoteProvider v ){ noteProvider = v ; return this ; }
107+ /** Inject a revocation provider for testing — bypasses HTTP. */
108+ public Builder revocationProvider (RevocationProvider v ){ revocationProvider = v ; return this ; }
105109
106110 public Verifier build () {
107111 Objects .requireNonNull (trust , "trust is required" );
108112 return new Verifier (trust ,
109113 httpClient != null ? httpClient :
110- // 10-second connect timeout prevents indefinite hangs on slow issuers.
111114 HttpClient .newBuilder ()
112115 .connectTimeout (Duration .ofSeconds (10 ))
113116 .build (),
114- noteProvider );
117+ noteProvider ,
118+ revocationProvider );
115119 }
116120 }
117121
@@ -121,14 +125,21 @@ public interface NoteProvider {
121125 CompletableFuture <String > fetchNote (String url );
122126 }
123127
128+ /** Provides revocation artifacts without HTTP. Used in tests. */
129+ @ FunctionalInterface
130+ public interface RevocationProvider {
131+ CompletableFuture <String > fetchArtifact (String url );
132+ }
133+
124134 // --- internals ---
125135
126136 private static final int GRACE = 600 ; // seconds
127137
128- private final TrustConfig trust ;
129- private final int batchSize ; // read from trust.batchSize at construction
130- private final HttpClient httpClient ;
131- private final NoteProvider noteProvider ;
138+ private final TrustConfig trust ;
139+ private final int batchSize ;
140+ private final HttpClient httpClient ;
141+ private final NoteProvider noteProvider ;
142+ private final RevocationProvider revocationProvider ;
132143 private static final int MAX_CACHE_ENTRIES = 1000 ;
133144 // Bounded insertion-order cache — evicts oldest entry when full.
134145 // Prevents memory exhaustion from payloads with rapidly incrementing tree_size.
@@ -140,11 +151,16 @@ public interface NoteProvider {
140151 }
141152 );
142153
143- private Verifier (TrustConfig trust , HttpClient httpClient , NoteProvider noteProvider ) {
144- this .trust = trust ;
145- this .batchSize = trust .batchSize > 0 ? trust .batchSize : 16 ;
146- this .httpClient = httpClient ;
147- this .noteProvider = noteProvider ;
154+ private record CachedRevocation (Cascade cascade , long treeSize ) {}
155+ private final Map <String , CachedRevocation > revocCache = new java .util .concurrent .ConcurrentHashMap <>();
156+
157+ private Verifier (TrustConfig trust , HttpClient httpClient ,
158+ NoteProvider noteProvider , RevocationProvider revocationProvider ) {
159+ this .trust = trust ;
160+ this .batchSize = trust .batchSize > 0 ? trust .batchSize : 16 ;
161+ this .httpClient = httpClient ;
162+ this .noteProvider = noteProvider ;
163+ this .revocationProvider = revocationProvider ;
148164 }
149165
150166 /**
@@ -312,13 +328,14 @@ private TraceResult runProofAndClaimsChecks(DecodedPayload p, byte[] rootHash, L
312328 steps .add (new Step ("cbor decode" , true ,
313329 "schema_id=" + schemaId + " issued=" + issuedAt + " expires=" + expiresAt ));
314330
315- // Revocation check — not yet implemented.
316- // The revocation protocol is fully specified in SPEC.md §Revocation:
317- // a Bloom filter cascade over revoked/valid entry indices, signed with
318- // the issuer key, served at GET /revoked (trust.revocationUrl).
319- // TODO: implement cascade fetch, cache, staleness check, and query.
320- // Fail-open is NOT the correct default. See issue #14.
321- steps .add (new Step ("revocation check" , false , "not implemented — stub only, revocation not checked" ));
331+ // 10. Revocation check — SPEC.md §Revocation.
332+ try {
333+ String revocMsg = checkRevocation (p .entryIndex , p .treeSize ).get ();
334+ steps .add (new Step ("revocation check" , true , revocMsg ));
335+ } catch (Exception e ) {
336+ String reason = e .getCause () != null ? e .getCause ().getMessage () : e .getMessage ();
337+ return fail (steps , "revocation check" , reason );
338+ }
322339
323340 // Expiry
324341 long now = Instant .now ().getEpochSecond ();
@@ -337,6 +354,98 @@ private TraceResult runProofAndClaimsChecks(DecodedPayload p, byte[] rootHash, L
337354
338355 // --- checkpoint fetch and note verification ---
339356
357+ // --- Revocation ---
358+
359+ private static final long STALE_THRESHOLD = 32L ;
360+
361+ private CompletableFuture <String > checkRevocation (long entryIndex , long checkpointTreeSize ) {
362+ if (trust .revocationUrl == null || trust .revocationUrl .isEmpty ())
363+ return CompletableFuture .completedFuture (
364+ "skipped — no revocation_url in trust config (fail-open)" );
365+
366+ CachedRevocation cached = revocCache .get (trust .origin );
367+ if (cached != null && checkpointTreeSize > cached .treeSize () &&
368+ checkpointTreeSize - cached .treeSize () > STALE_THRESHOLD )
369+ cached = null ;
370+
371+ final CachedRevocation fresh = cached ;
372+ if (fresh == null ) {
373+ return fetchRevocationArtifact ().thenApply (art -> {
374+ revocCache .put (trust .origin , art );
375+ return queryRevocation (art , entryIndex );
376+ });
377+ }
378+ return CompletableFuture .completedFuture (queryRevocation (fresh , entryIndex ));
379+ }
380+
381+ private String queryRevocation (CachedRevocation art , long entryIndex ) {
382+ if (art .treeSize () <= entryIndex )
383+ throw new IllegalStateException ("entry_index=" + entryIndex +
384+ " not covered by artifact (tree_size=" + art .treeSize () + ") — fail-closed" );
385+ if (art .cascade ().query (entryIndex ))
386+ throw new IllegalStateException ("entry_index=" + entryIndex + " is revoked" );
387+ return "entry_index=" + entryIndex + " not revoked (cascade checked, artifact tree_size=" +
388+ art .treeSize () + ")" ;
389+ }
390+
391+ private CompletableFuture <CachedRevocation > fetchRevocationArtifact () {
392+ String url = trust .revocationUrl ;
393+ if (revocationProvider != null )
394+ return revocationProvider .fetchArtifact (url )
395+ .thenApply (this ::parseRevocationArtifact );
396+ HttpRequest req = HttpRequest .newBuilder ()
397+ .uri (URI .create (url ))
398+ .timeout (Duration .ofSeconds (10 ))
399+ .GET ().build ();
400+ return httpClient .sendAsync (req , HttpResponse .BodyHandlers .ofString ())
401+ .thenApply (resp -> {
402+ if (resp .statusCode () != 200 )
403+ throw new IllegalStateException ("GET " + url + " → " + resp .statusCode ());
404+ if (resp .body ().length () > 64 * 1024 )
405+ throw new IllegalStateException ("revocation artifact too large" );
406+ return parseRevocationArtifact (resp .body ());
407+ });
408+ }
409+
410+ private CachedRevocation parseRevocationArtifact (String text ) {
411+ int sep = text .indexOf ("\n \n " );
412+ if (sep < 0 ) throw new IllegalArgumentException ("revocation artifact: missing blank line" );
413+ String bodyPart = text .substring (0 , sep );
414+ String sigPart = text .substring (sep + 2 );
415+ String body = bodyPart + "\n " ;
416+
417+ String [] lines = bodyPart .split ("\n " , -1 );
418+ if (lines .length != 4 )
419+ throw new IllegalArgumentException ("revocation artifact: expected 4 body lines, got " + lines .length );
420+ if (!lines [0 ].equals (trust .origin ))
421+ throw new IllegalArgumentException ("revocation artifact: origin mismatch" );
422+ if (!"mta-qr-revocation-v1" .equals (lines [2 ]))
423+ throw new IllegalArgumentException ("revocation artifact: unknown type: " + lines [2 ]);
424+ long treeSize = Long .parseLong (lines [1 ]);
425+ if (treeSize <= 0 )
426+ throw new IllegalArgumentException ("revocation artifact: tree_size must be > 0" );
427+
428+ byte [] cascBytes = Base64 .getDecoder ().decode (lines [3 ]);
429+ Cascade cascade = Cascade .decode (cascBytes );
430+
431+ // Verify signature — algorithm binding per SPEC.md.
432+ byte [] bodyBytes = body .getBytes (StandardCharsets .UTF_8 );
433+ String keyPrefix = "\u2014 " + trust .issuerKeyName + " " ;
434+ boolean sigOk = false ;
435+ for (String line : sigPart .split ("\n " )) {
436+ if (!line .startsWith (keyPrefix )) continue ;
437+ byte [] sigPayload = Base64 .getDecoder ().decode (line .substring (keyPrefix .length ()).trim ());
438+ if (sigPayload .length < 4 ) continue ;
439+ byte [] sig = Arrays .copyOfRange (sigPayload , 4 , sigPayload .length );
440+ if (SignatureVerifier .verify (trust .sigAlg , bodyBytes , sig , trust .issuerPubKey )) {
441+ sigOk = true ; break ;
442+ }
443+ }
444+ if (!sigOk ) throw new IllegalArgumentException ("revocation artifact: signature verification failed" );
445+
446+ return new CachedRevocation (cascade , treeSize );
447+ }
448+
340449 private record CheckpointResult (byte [] rootHash , long treeSize ) {}
341450
342451 private CompletableFuture <CheckpointResult > fetchAndVerifyCheckpoint (long requiredSize ) {
0 commit comments