Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ These are genuine gaps that should be addressed before production use. They are

**Revocation delay.** When an entry is revoked, the revocation is effective only once a new checkpoint has been issued and the verifier's cached artifact becomes stale (STALE_THRESHOLD=32 entries). Between revocation and cache expiry, a revoked credential may still pass verification. This is an inherent trade-off in transparency log models — the window is bounded by checkpoint frequency and STALE_THRESHOLD, not unbounded. Deployments with strict revocation requirements should set short checkpoint intervals and tune STALE_THRESHOLD accordingly.

**Revocation auditability implemented (tamper-evident, not fully auditable).** All four SDK issuers (Go, TypeScript, Rust, Java) commit `revoc:<hex(SHA-256(artifact))>` as a 4th extension line in each checkpoint body. This line is covered by all signatures — the issuer signature and all witness cosignatures — so every witnessed checkpoint attests to the revocation state at that moment. All four verifiers parse the `revoc:` line when present. Active cross-checking of the committed hash against the fetched artifact is advisory (logged but not enforced as a hard rejection) — the commitment is present and signed but not yet a mandatory verification step. See SPEC.md §Revocation — Auditability for the full model and what full auditability would additionally require.
**Revocation auditability implemented (tamper-evident, not fully auditable).** All four SDK issuers (Go, TypeScript, Rust, Java) commit `revoc:<hex(SHA-256(artifact))>` as a 4th extension line in each checkpoint body. This line is covered by all signatures — the issuer signature and all witness cosignatures — so every witnessed checkpoint attests to the revocation state at that moment. All four verifiers parse the `revoc:` line when present. All four verifiers enforce the hash: when a freshly fetched artifact predates the checkpoint (artifact `tree_size` < checkpoint `tree_size`) and the SHA-256 does not match the committed hash, the verification is rejected. When the artifact is newer than the checkpoint (e.g., a revoke was processed after signing), the check is skipped to allow normal revocation workflows. See SPEC.md §Revocation — Auditability for the full model and what full auditability would additionally require.

**Revocation is implemented** in all four language SDKs (Go, TypeScript, Rust, Java). Issuers serve a signed Bloom filter cascade at `GET /revoked` and accept `POST /revoke` for demo purposes. Verifiers fetch the artifact on cache miss, verify the issuer signature with algorithm binding, apply a staleness check (STALE_THRESHOLD=32 entries), and query the cascade fail-closed. The cascade algorithm is cross-verified against locked test vector bytes in all four languages. See SPEC.md §Revocation for the normative wire format and construction parameters.

Expand Down
20 changes: 10 additions & 10 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1499,16 +1499,16 @@ checkpoint signature automatically covers the revocation state at that moment.
Witnesses attest to the revocation state without any change to the witness
protocol.

All four issuers (Go, TypeScript, Rust, Java) now emit the `revoc:` extension
line when a revocation artifact is available. All four verifiers parse the line
and extract the hash. Active enforcement — rejecting a verification result when
the hash of the fetched artifact does not match the committed hash — is currently
advisory in all four verifiers (the hash is extracted and available but not used
as a hard rejection criterion). The remaining step to complete this is:

1. All verifiers: after fetching the revocation artifact, compute
`SHA-256(artifact_bytes)`, find the `revoc:` line in the checkpoint body,
and reject if the hashes do not match (currently logged only)
All four issuers (Go, TypeScript, Rust, Java) emit the `revoc:` extension line
when a revocation artifact is available. All four verifiers enforce the hash:
when a freshly fetched artifact has `artifact.tree_size < checkpoint.tree_size`
(the artifact predates the checkpoint, indicating no legitimate update has
occurred) and the SHA-256 of the artifact does not match the committed hash,
the verification result is a hard rejection. When `artifact.tree_size >=
checkpoint.tree_size` (the artifact has been updated since the checkpoint was
signed, e.g. a revocation was processed), the hash check is skipped — this
accommodates the normal case where the issuer has updated the artifact after
the checkpoint was witnessed.

Once step 2 is complete, a verifier that checks both the checkpoint signature
(covering the revoc hash) and the fetched artifact against that hash has an
Expand Down
15 changes: 15 additions & 0 deletions go/issuer/log/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,22 @@ type SignedCheckpoint struct {
}

// New creates a Log with the given origin and issuer Signer.
// registeredOrigins guards against two Log instances sharing the same origin
// string within a process. Same origin → same origin_id → ambiguous routing.
var registeredOrigins sync.Map

// DeregisterOrigin removes an origin from the process-wide registry.
// Only intended for use in tests — production code should never
// create two Log instances with the same origin.
func DeregisterOrigin(origin string) { registeredOrigins.Delete(origin) }

func New(origin string, issuer signing.Signer) (*Log, error) {
if origin == "" {
return nil, fmt.Errorf("issuer: origin must not be empty")
}
if _, loaded := registeredOrigins.LoadOrStore(origin, struct{}{}); loaded {
return nil, fmt.Errorf("issuer: origin %q is already registered in this process — each origin must be unique", origin)
}
witnesses := make([]WitnessKey, 2)
for i := range witnesses {
wpub, wpriv, err := ed25519.GenerateKey(rand.Reader)
Expand Down
6 changes: 5 additions & 1 deletion go/issuer/log/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ func newTestLog(t *testing.T) *log.Log {
if err != nil {
t.Fatalf("Ed25519FromSeed: %v", err)
}
l, err := log.New("example.com/log-test/v1", signer)
// Each test gets a unique origin derived from its name so the
// process-wide uniqueness guard does not fire across test functions.
origin := "example.com/log-test/" + t.Name() + "/v1"
l, err := log.New(origin, signer)
if err != nil {
t.Fatalf("log.New: %v", err)
}
t.Cleanup(func() { log.DeregisterOrigin(origin) })
return l
}

Expand Down
66 changes: 42 additions & 24 deletions go/verifier/verify/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ const maxCacheEntries = 1000

// CachedRevocation holds a locally cached, verified revocation artifact.
type CachedRevocation struct {
Cascade *cascade.Cascade
TreeSize uint64
CascadeHash [32]byte // SHA-256 of raw cascade bytes for in-memory integrity
Cascade *cascade.Cascade
TreeSize uint64
CascadeHash [32]byte // SHA-256 of raw cascade bytes for in-memory integrity
ArtifactHash [32]byte // SHA-256 of complete raw artifact bytes (body + sig line)
}

// Verifier holds trust anchors, a checkpoint cache, and a revocation cache.
Expand Down Expand Up @@ -191,6 +192,7 @@ func (v *Verifier) Verify(payloadBytes []byte) *Result {

// 6. Checkpoint resolution — method depends on mode.
var rootHash []byte
var checkpointRevocHash []byte // revoc: hash from checkpoint body; nil if not present
if p.Mode == payload.ModeEmbedded {
// Mode 0: verify the checkpoint embedded directly in the payload.
// No network access — the issuer sig and witness cosigs are in the payload bytes.
Expand All @@ -215,10 +217,11 @@ func (v *Verifier) Verify(payloadBytes []byte) *Result {
rootHash = cached.RootHash
} else {
add("Checkpoint cache", false, fmt.Sprintf("cache miss · tree_size=%d · fetching from %s", p.TreeSize, anchor.CheckpointURL))
fetchedRoot, fetchedSize, ferr := v.fetchAndVerify(anchor, p.TreeSize)
fetchedRoot, fetchedSize, fetchedRevocHash, ferr := v.fetchAndVerify(anchor, p.TreeSize)
if ferr != nil {
return fail("Checkpoint fetch+verify", ferr.Error())
}
checkpointRevocHash = fetchedRevocHash
add("Checkpoint fetch+verify", true, fmt.Sprintf("issuer sig ✓ · %d/%d witnesses ✓ · tree_size=%d",
anchor.WitnessQuorum, anchor.WitnessQuorum, fetchedSize))
rootHash = fetchedRoot
Expand Down Expand Up @@ -324,7 +327,7 @@ func (v *Verifier) Verify(payloadBytes []byte) *Result {
res.SchemaID = entry.SchemaID

// 10. Revocation check — SPEC.md §Revocation — Verifier Behavior.
if err := v.checkRevocation(anchor, p.EntryIndex, p.TreeSize, add); err != nil {
if err := v.checkRevocation(anchor, p.EntryIndex, p.TreeSize, checkpointRevocHash, add); err != nil {
return fail("Revocation check", err.Error())
}

Expand Down Expand Up @@ -393,17 +396,17 @@ func (v *Verifier) verifyEmbeddedCheckpoint(p *payload.Payload, anchor *TrustAnc
}


func (v *Verifier) fetchAndVerify(anchor *TrustAnchor, requiredSize uint64) ([]byte, uint64, error) {
func (v *Verifier) fetchAndVerify(anchor *TrustAnchor, requiredSize uint64) (rootHash []byte, treeSize uint64, revocHash []byte, err error) {
resp, err := v.httpClient.Get(anchor.CheckpointURL)
if err != nil {
return nil, 0, fmt.Errorf("GET %s: %w", anchor.CheckpointURL, err)
return nil, 0, nil, fmt.Errorf("GET %s: %w", anchor.CheckpointURL, err)
}
defer resp.Body.Close()
// Limit to 64 KB — a valid checkpoint is ~200 bytes.
const maxCheckpointBytes = 64 * 1024
buf, err := io.ReadAll(io.LimitReader(resp.Body, maxCheckpointBytes))
if err != nil {
return nil, 0, fmt.Errorf("read checkpoint body: %w", err)
return nil, 0, nil, fmt.Errorf("read checkpoint body: %w", err)
}
return verifyNote(string(buf), anchor, requiredSize)
}
Expand All @@ -413,23 +416,23 @@ func (v *Verifier) fetchAndVerify(anchor *TrustAnchor, requiredSize uint64) ([]b
// against the anchor's known issuer key name — not by byte length, which is
// ambiguous when multiple algorithms share the same sig size (Ed25519 and
// ECDSA-P256 are both 64 bytes raw).
func verifyNote(note string, anchor *TrustAnchor, requiredSize uint64) ([]byte, uint64, error) {
func verifyNote(note string, anchor *TrustAnchor, requiredSize uint64) (rootHash []byte, treeSize uint64, revocHash []byte, err error) {
blankIdx := strings.Index(note, "\n\n")
if blankIdx < 0 {
return nil, 0, fmt.Errorf("note missing blank-line separator between body and signatures")
return nil, 0, nil, fmt.Errorf("note missing blank-line separator between body and signatures")
}
body := []byte(note[:blankIdx] + "\n")
rest := note[blankIdx+2:]

origin, treeSize, rootHash, err := checkpoint.ParseBody(body)
if err != nil {
return nil, 0, fmt.Errorf("parse body: %w", err)
return nil, 0, nil, fmt.Errorf("parse body: %w", err)
}
if origin != anchor.Origin {
return nil, 0, fmt.Errorf("origin mismatch: got %q want %q", origin, anchor.Origin)
return nil, 0, nil, fmt.Errorf("origin mismatch: got %q want %q", origin, anchor.Origin)
}
if treeSize < requiredSize {
return nil, 0, fmt.Errorf("tree_size %d < required %d", treeSize, requiredSize)
return nil, 0, nil, fmt.Errorf("tree_size %d < required %d", treeSize, requiredSize)
}

var sigLines []string
Expand Down Expand Up @@ -462,7 +465,7 @@ func verifyNote(note string, anchor *TrustAnchor, requiredSize uint64) ([]byte,
}
}
if !issuerOK {
return nil, 0, fmt.Errorf("%s issuer signature not found or invalid", signing.SigAlgName(anchor.SigAlg))
return nil, 0, nil, fmt.Errorf("%s issuer signature not found or invalid", signing.SigAlgName(anchor.SigAlg))
}

// Verify witness cosignatures. Per c2sp.org/tlog-cosignature, witness keys
Expand Down Expand Up @@ -495,23 +498,21 @@ func verifyNote(note string, anchor *TrustAnchor, requiredSize uint64) ([]byte,
}
}
if len(verifiedWitnesses) < anchor.WitnessQuorum {
return nil, 0, fmt.Errorf("witness quorum not met: %d/%d verified", len(verifiedWitnesses), anchor.WitnessQuorum)
return nil, 0, nil, fmt.Errorf("witness quorum not met: %d/%d verified", len(verifiedWitnesses), anchor.WitnessQuorum)
}

// Extract optional revoc: extension line from the checkpoint body.
// If present, callers can verify their cached revocation artifact matches.
var revocHash []byte
for _, line := range strings.Split(string(body), "\n") {
if strings.HasPrefix(line, "revoc:") {
h, err := hex.DecodeString(strings.TrimPrefix(line, "revoc:"))
if err == nil && len(h) == 32 {
h, e2 := hex.DecodeString(strings.TrimPrefix(line, "revoc:"))
if e2 == nil && len(h) == 32 {
revocHash = h
}
break
}
}
_ = revocHash // available for callers that implement full revoc auditability
return rootHash, treeSize, nil
return rootHash, treeSize, revocHash, nil
}

func lastFieldBase64(line string) ([]byte, error) {
Expand Down Expand Up @@ -541,7 +542,7 @@ func entryTypeName(t byte) string {
// checkRevocation implements SPEC.md §Revocation — Verifier Behavior.
// It checks the revocation cache, fetches if stale or missing, and queries.
// Returns nil if the entry is not revoked, or an error if revoked or check failed.
func (v *Verifier) checkRevocation(anchor *TrustAnchor, entryIndex, checkpointTreeSize uint64, add addFn) error {
func (v *Verifier) checkRevocation(anchor *TrustAnchor, entryIndex, checkpointTreeSize uint64, committedRevocHash []byte, add addFn) error {
if anchor.RevocationURL == "" {
add("Revocation check", true, "skipped — no revocation_url in trust config (fail-open)")
return nil
Expand Down Expand Up @@ -572,6 +573,21 @@ func (v *Verifier) checkRevocation(anchor *TrustAnchor, entryIndex, checkpointTr
cached = art
}

// Revocation artifact integrity: if the checkpoint body committed a revoc: hash,
// verify it matches the artifact — but only when the artifact is from the same
// point in time as the checkpoint (artifact.TreeSize < checkpoint.TreeSize means
// the artifact predates the checkpoint and may have been updated legitimately).
if len(committedRevocHash) == 32 && cached.TreeSize < checkpointTreeSize {
if cached.ArtifactHash != ([32]byte)(committedRevocHash) {
add("Revocation check", false, fmt.Sprintf(
"artifact hash mismatch: checkpoint committed %x but served artifact hashes to %x",
committedRevocHash, cached.ArtifactHash[:]))
return fmt.Errorf("revocation artifact hash mismatch: committed %x, got %x",
committedRevocHash, cached.ArtifactHash[:])
}
add("Revocation check", true, fmt.Sprintf("artifact hash matches committed revoc: %x", committedRevocHash[:8]))
}

// Coverage check.
if cached.TreeSize <= entryIndex {
return fmt.Errorf("entry_index %d not covered by revocation artifact (tree_size=%d) — fetch fresh artifact", entryIndex, cached.TreeSize)
Expand Down Expand Up @@ -658,10 +674,12 @@ func parseRevocationArtifact(anchor *TrustAnchor, raw []byte) (*CachedRevocation
}

cascHash := sha256.Sum256(cascadeBytes)
artHash := sha256.Sum256(raw)
return &CachedRevocation{
Cascade: casc,
TreeSize: treeSize,
CascadeHash: cascHash,
Cascade: casc,
TreeSize: treeSize,
CascadeHash: cascHash,
ArtifactHash: artHash,
}, nil
}

Expand Down
11 changes: 5 additions & 6 deletions java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,11 @@ private CompletableFuture<Void> publishCheckpoint() {
}
byte[] parentRoot = merkleRoot(bRoots);
byte[] plainBody = checkpointBody(origin, treeSize, parentRoot);
// Build revocation artifact first; commit its hash in the checkpoint body.
String revocSnap;
synchronized (lock) { revocSnap = latestRevArtifact; }
byte[] body = (revocSnap != null)
// Build NEW artifact first so its hash is committed in the checkpoint.
String newRevocArt = buildRevocationArtifact(treeSize);
byte[] body = (newRevocArt != null)
? checkpointBodyWithRevoc(origin, treeSize, parentRoot,
revocSnap.getBytes(java.nio.charset.StandardCharsets.UTF_8))
newRevocArt.getBytes(java.nio.charset.StandardCharsets.UTF_8))
: plainBody;
final byte[] finalBody = body, finalPlainBody = plainBody;

Expand Down Expand Up @@ -304,7 +303,7 @@ private CompletableFuture<Void> publishCheckpoint() {
latestCkpt = new SignedCheckpoint(
treeSize, parentRoot, finalBody, issuerSig, cosigs,
finalPlainBody, plainSig, plainCosigs);
latestRevArtifact = buildRevocationArtifact(treeSize);
latestRevArtifact = newRevocArt;
}
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ public interface RevocationProvider {
}
);

private record CachedRevocation(Cascade cascade, long treeSize) {}
private record CachedRevocation(Cascade cascade, long treeSize, byte[] artifactHash) {}
private final Map<String, CachedRevocation> revocCache = new java.util.concurrent.ConcurrentHashMap<>();

private Verifier(HttpClient httpClient,
Expand Down Expand Up @@ -405,7 +405,7 @@ private TraceResult runProofAndClaimsChecks(DecodedPayload p, byte[] rootHash, L

// 10. Revocation check — SPEC.md §Revocation.
try {
String revocMsg = checkRevocation(p.entryIndex, p.treeSize, trust).get();
String revocMsg = checkRevocation(p.entryIndex, p.treeSize, trust, null).get();
steps.add(new Step("revocation check", true, revocMsg));
} catch (Exception e) {
String reason = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
Expand Down Expand Up @@ -433,7 +433,7 @@ private TraceResult runProofAndClaimsChecks(DecodedPayload p, byte[] rootHash, L

private static final long STALE_THRESHOLD = 32L;

private CompletableFuture<String> checkRevocation(long entryIndex, long checkpointTreeSize, TrustConfig trust) {
private CompletableFuture<String> checkRevocation(long entryIndex, long checkpointTreeSize, TrustConfig trust, byte[] committedRevocHash) {
if (trust.revocationUrl == null || trust.revocationUrl.isEmpty())
return CompletableFuture.completedFuture(
"skipped — no revocation_url in trust config (fail-open)");
Expand All @@ -446,6 +446,14 @@ private CompletableFuture<String> checkRevocation(long entryIndex, long checkpoi
final CachedRevocation fresh = cached;
if (fresh == null) {
return fetchRevocationArtifact(trust).thenApply(art -> {
// Revoc hash enforcement: only when artifact is older than checkpoint.
if (committedRevocHash != null && art.treeSize() < checkpointTreeSize
&& !java.util.Arrays.equals(art.artifactHash(), committedRevocHash))
throw new IllegalStateException(
"revocation artifact hash mismatch: checkpoint committed "
+ bytesToHex(committedRevocHash).substring(0,16) + "…"
+ " but served artifact hashes to "
+ bytesToHex(art.artifactHash()).substring(0,16) + "…");
revocCache.put(trust.origin, art);
return queryRevocation(art, entryIndex);
});
Expand Down Expand Up @@ -519,7 +527,12 @@ private CachedRevocation parseRevocationArtifact(String text, TrustConfig trust)
}
if (!sigOk) throw new IllegalArgumentException("revocation artifact: signature verification failed");

return new CachedRevocation(cascade, treeSize);
byte[] artHash;
try {
artHash = java.security.MessageDigest.getInstance("SHA-256")
.digest(text.getBytes(java.nio.charset.StandardCharsets.UTF_8));
} catch (java.security.NoSuchAlgorithmException e) { throw new RuntimeException(e); }
return new CachedRevocation(cascade, treeSize, artHash);
}

private record CheckpointResult(byte[] rootHash, long treeSize) {}
Expand Down
Loading
Loading