Skip to content

Commit 01a80c3

Browse files
authored
feat: complete revocation in Rust and Java SDKs
feat: complete revocation in Rust and Java SDKs
2 parents 7719326 + c474479 commit 01a80c3

5 files changed

Lines changed: 348 additions & 33 deletions

File tree

java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.peculiarventures.mtaqr.issuer;
22

33
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.peculiarventures.mtaqr.cascade.Cascade;
45
import com.peculiarventures.mtaqr.signing.Signer;
56
import com.peculiarventures.mtaqr.trust.TrustConfig;
67

@@ -107,6 +108,8 @@ private record WitnessCosig(byte[] keyId, long timestamp, byte[] signature) {}
107108
private List<Batch> batches = new ArrayList<>();
108109
private List<LogEntry> currentBatch = new ArrayList<>();
109110
private SignedCheckpoint latestCkpt;
111+
private final Set<Long> revokedIndices = new HashSet<>();
112+
private volatile String latestRevArtifact;
110113

111114
private Issuer(String origin, long schemaId, int mode, int batchSize, int witnessCount, Signer signer) {
112115
this.origin = origin;
@@ -191,6 +194,7 @@ public String trustConfigJson(String checkpointUrl) {
191194
Map.entry("sig_alg", signer.getAlg()),
192195
Map.entry("witness_quorum", witnesses.size()),
193196
Map.entry("checkpoint_url", checkpointUrl),
197+
Map.entry("revocation_url", revocationUrlFrom(checkpointUrl)),
194198
Map.entry("batch_size", batchSize),
195199
Map.entry("witnesses", wList)
196200
));
@@ -278,6 +282,7 @@ private CompletableFuture<Void> publishCheckpoint() {
278282
}
279283
synchronized (lock) {
280284
latestCkpt = new SignedCheckpoint(treeSize, parentRoot, body, issuerSig, cosigs);
285+
latestRevArtifact = buildRevocationArtifact(treeSize);
281286
}
282287
});
283288
}
@@ -330,6 +335,69 @@ public static byte[] encodeTbsForTest(long issuedAt, long expiresAt, long schema
330335
return encodeTbsStatic(issuedAt, expiresAt, schemaId, claims);
331336
}
332337

338+
public void revoke(long entryIndex) {
339+
synchronized (lock) {
340+
if (entryIndex == 0)
341+
throw new IllegalArgumentException("entry_index=0 is the null entry");
342+
if (entryIndex >= totalEntriesLocked())
343+
throw new IllegalArgumentException("entry_index " + entryIndex + " not yet issued");
344+
revokedIndices.add(entryIndex);
345+
if (latestCkpt != null)
346+
latestRevArtifact = buildRevocationArtifact(latestCkpt.treeSize());
347+
}
348+
}
349+
350+
public String revocationArtifact() { return latestRevArtifact; }
351+
352+
private static String revocationUrlFrom(String checkpointUrl) {
353+
return checkpointUrl.endsWith("/checkpoint")
354+
? checkpointUrl.substring(0, checkpointUrl.length() - 11) + "/revoked"
355+
: checkpointUrl.replace("checkpoint", "revoked");
356+
}
357+
358+
private String buildRevocationArtifact(long treeSize) {
359+
long now = Instant.now().getEpochSecond();
360+
List<Long> revoked = new ArrayList<>(), valid = new ArrayList<>();
361+
List<LogEntry> all = new ArrayList<>();
362+
synchronized (lock) {
363+
for (Batch b : batches) all.addAll(b.entries());
364+
all.addAll(currentBatch);
365+
}
366+
for (LogEntry e : all) {
367+
if (e.index() == 0) continue;
368+
if (revokedIndices.contains(e.index())) { revoked.add(e.index()); continue; }
369+
long exp = entryExpiryTimestamp(e.tbs());
370+
if (exp > 0 && exp < now) continue;
371+
valid.add(e.index());
372+
}
373+
long[] r = revoked.stream().mapToLong(Long::longValue).toArray();
374+
long[] v = valid.stream().mapToLong(Long::longValue).toArray();
375+
Cascade casc = Cascade.build(r, v);
376+
String cascB64 = Base64.getEncoder().encodeToString(casc.encode());
377+
String body = origin + "\n" + treeSize + "\nmta-qr-revocation-v1\n" + cascB64 + "\n";
378+
byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
379+
byte[] sig;
380+
try { sig = signer.sign(bodyBytes).get(); }
381+
catch (Exception e) { throw new RuntimeException("revocation sign failed", e); }
382+
byte[] keyId = witnessKeyId(signer.getKeyName(), issuerPub);
383+
byte[] payload = new byte[4 + sig.length];
384+
System.arraycopy(keyId, 0, payload, 0, 4);
385+
System.arraycopy(sig, 0, payload, 4, sig.length);
386+
String sigLine = "\n\u2014 " + signer.getKeyName() + " " +
387+
Base64.getEncoder().encodeToString(payload) + "\n";
388+
return body + sigLine;
389+
}
390+
391+
private static long entryExpiryTimestamp(byte[] tbs) {
392+
if (tbs == null || tbs.length < 2 || tbs[0] != ENTRY_TYPE_DATA) return 0;
393+
try {
394+
var obj = com.upokecenter.cbor.CBORObject.DecodeFromBytes(
395+
java.util.Arrays.copyOfRange(tbs, 1, tbs.length));
396+
var times = obj.get(com.upokecenter.cbor.CBORObject.FromObject(2));
397+
return (times != null && times.size() >= 2) ? times.get(1).AsInt64Value() : 0;
398+
} catch (Exception e) { return 0; }
399+
}
400+
333401
private static byte[] entryHash(byte[] tbs) {
334402
return hashLeaf(tbs);
335403
}

java/src/main/java/com/peculiarventures/mtaqr/verifier/Verifier.java

Lines changed: 130 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.peculiarventures.mtaqr.issuer.Issuer;
44
import com.peculiarventures.mtaqr.signing.Signer;
55
import com.peculiarventures.mtaqr.signing.SignatureVerifier;
6+
import com.peculiarventures.mtaqr.cascade.Cascade;
67
import com.peculiarventures.mtaqr.trust.TrustConfig;
78

89
import 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) {

java/src/test/java/com/peculiarventures/mtaqr/MlDsaVerifyTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,11 @@ public void issueAndVerifyEndToEnd() throws Exception {
7777
System.out.println("note length: " + noteString.length());
7878

7979
TrustConfig trust = TrustConfig.parse(trustJson);
80+
String revArtifact = issuer.revocationArtifact();
8081
Verifier verifier = Verifier.builder()
8182
.trust(trust)
8283
.noteProvider(url -> java.util.concurrent.CompletableFuture.completedFuture(noteString))
84+
.revocationProvider(url -> java.util.concurrent.CompletableFuture.completedFuture(revArtifact))
8385
.build();
8486

8587
// verify() returns VerifyOk on success, throws VerifyFail exceptionally on failure.

java/src/test/java/com/peculiarventures/mtaqr/SmokeTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@ private void roundTrip(String label, LocalSigner signer) throws Exception {
4040
var qr = issuer.issue(Map.of("subject", "test"), Duration.ofHours(1)).get();
4141
TrustConfig trust = TrustConfig.parse(issuer.trustConfigJson("http://localhost:0/checkpoint"));
4242
String note = issuer.checkpointNote();
43+
String revArtifact = issuer.revocationArtifact();
4344
Verifier v = Verifier.builder()
4445
.trust(trust)
4546
.noteProvider(url -> CompletableFuture.completedFuture(note))
47+
.revocationProvider(url -> CompletableFuture.completedFuture(revArtifact))
4648
.build();
4749

4850
var result = v.verify(qr.payload()).get();
@@ -61,9 +63,11 @@ private void rejectTampered(String label, LocalSigner signer) throws Exception {
6163

6264
TrustConfig trust = TrustConfig.parse(issuer.trustConfigJson("http://localhost:0/checkpoint"));
6365
String note = issuer.checkpointNote();
66+
String revArtifact = issuer.revocationArtifact();
6467
Verifier v = Verifier.builder()
6568
.trust(trust)
6669
.noteProvider(url -> CompletableFuture.completedFuture(note))
70+
.revocationProvider(url -> CompletableFuture.completedFuture(revArtifact))
6771
.build();
6872

6973
assertThrows(Exception.class, () -> v.verify(tampered).get(), label + ": tampered payload should fail");

0 commit comments

Comments
 (0)