diff --git a/README.md b/README.md index 3e9df4d..b9a4ecd 100644 --- a/README.md +++ b/README.md @@ -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:` 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:` 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. diff --git a/SPEC.md b/SPEC.md index 4095b40..5a5472c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 diff --git a/go/issuer/log/log.go b/go/issuer/log/log.go index 4bd8a52..4623a67 100644 --- a/go/issuer/log/log.go +++ b/go/issuer/log/log.go @@ -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) diff --git a/go/issuer/log/log_test.go b/go/issuer/log/log_test.go index 411cdcc..70e60f2 100644 --- a/go/issuer/log/log_test.go +++ b/go/issuer/log/log_test.go @@ -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 } diff --git a/go/verifier/verify/verify.go b/go/verifier/verify/verify.go index 96329e6..0714d0b 100644 --- a/go/verifier/verify/verify.go +++ b/go/verifier/verify/verify.go @@ -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. @@ -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. @@ -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 @@ -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()) } @@ -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) } @@ -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 @@ -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 @@ -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) { @@ -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 @@ -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) @@ -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 } diff --git a/java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java b/java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java index 41e1f95..3929cfb 100644 --- a/java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java +++ b/java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java @@ -271,12 +271,11 @@ private CompletableFuture 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; @@ -304,7 +303,7 @@ private CompletableFuture publishCheckpoint() { latestCkpt = new SignedCheckpoint( treeSize, parentRoot, finalBody, issuerSig, cosigs, finalPlainBody, plainSig, plainCosigs); - latestRevArtifact = buildRevocationArtifact(treeSize); + latestRevArtifact = newRevocArt; } })); } diff --git a/java/src/main/java/com/peculiarventures/mtaqr/verifier/Verifier.java b/java/src/main/java/com/peculiarventures/mtaqr/verifier/Verifier.java index aad7389..a8f0841 100644 --- a/java/src/main/java/com/peculiarventures/mtaqr/verifier/Verifier.java +++ b/java/src/main/java/com/peculiarventures/mtaqr/verifier/Verifier.java @@ -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 revocCache = new java.util.concurrent.ConcurrentHashMap<>(); private Verifier(HttpClient httpClient, @@ -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(); @@ -433,7 +433,7 @@ private TraceResult runProofAndClaimsChecks(DecodedPayload p, byte[] rootHash, L private static final long STALE_THRESHOLD = 32L; - private CompletableFuture checkRevocation(long entryIndex, long checkpointTreeSize, TrustConfig trust) { + private CompletableFuture 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)"); @@ -446,6 +446,14 @@ private CompletableFuture 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); }); @@ -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) {} diff --git a/rust/src/issuer/mod.rs b/rust/src/issuer/mod.rs index 4bd774a..60f4d77 100644 --- a/rust/src/issuer/mod.rs +++ b/rust/src/issuer/mod.rs @@ -20,6 +20,26 @@ use sha2::{Sha256, Digest}; use std::collections::HashSet; use std::sync::Arc; use tokio::sync::Mutex; + +// Process-wide origin registry. +static REGISTERED_ORIGINS: std::sync::Mutex>> = + std::sync::Mutex::new(None); + +fn register_origin(origin: &str) -> anyhow::Result<()> { + let mut guard = REGISTERED_ORIGINS.lock().unwrap(); + let set = guard.get_or_insert_with(std::collections::HashSet::new); + if !set.insert(origin.to_string()) { + return Err(anyhow!("Issuer: origin {:?} is already registered in this process", origin)); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) fn deregister_origin(origin: &str) { + if let Ok(mut g) = REGISTERED_ORIGINS.lock() { + if let Some(s) = g.as_mut() { s.remove(origin); } + } +} use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// Configuration for an Issuer. @@ -108,6 +128,8 @@ pub struct Issuer { impl Issuer { pub fn new(cfg: IssuerConfig, signer: impl Signer + 'static) -> Self { + if cfg.origin.is_empty() { panic!("Issuer: origin must not be empty"); } + register_origin(&cfg.origin).expect("Issuer origin uniqueness violated"); let batch_size = cfg.batch_size.unwrap_or(16); Self { cfg, diff --git a/rust/src/verifier/mod.rs b/rust/src/verifier/mod.rs index 75eb252..4411bc9 100644 --- a/rust/src/verifier/mod.rs +++ b/rust/src/verifier/mod.rs @@ -67,8 +67,9 @@ pub type RevocationProvider = Box Result + Send + Sync> /// Cached revocation artifact per origin. struct CachedRevocation { - cascade: crate::cascade::Cascade, - tree_size: u64, + cascade: crate::cascade::Cascade, + tree_size: u64, + artifact_hash: [u8; 32], } /// The MTA-QR verifier. @@ -357,7 +358,7 @@ impl Verifier { // 10. Revocation check — SPEC.md §Revocation. { - let revoc = self.check_revocation(p.entry_index, p.tree_size, trust).await; + let revoc = self.check_revocation(p.entry_index, p.tree_size, trust, None).await; match revoc { Ok(ref msg) => { add!(true, "revocation check", msg.as_str()); } Err(ref msg) => { fail!("revocation check", msg.as_str()); } @@ -493,7 +494,7 @@ impl Verifier { Ok((root_hash, tree_size)) } /// Revocation check — SPEC.md §Revocation — Verifier Behavior. - async fn check_revocation(&self, entry_index: u64, checkpoint_tree_size: u64, trust: &TrustConfig) -> Result { + async fn check_revocation(&self, entry_index: u64, checkpoint_tree_size: u64, trust: &TrustConfig, committed_revoc_hash: Option<[u8;32]>) -> Result { if trust.revocation_url.is_empty() { return Ok("skipped — no revocation_url in trust config (fail-open)".into()); } @@ -539,6 +540,16 @@ impl Verifier { )); } + // Revoc hash enforcement: if checkpoint committed a hash, verify it matches. + // Only enforce when artifact.tree_size < checkpoint_tree_size — if the + // artifact is newer, it was legitimately updated (e.g., after a revoke). + if let Some(committed) = committed_revoc_hash { + if artifact.tree_size < checkpoint_tree_size && artifact.artifact_hash != committed { + return Err(format!( + "revocation artifact hash mismatch: checkpoint committed {:x?}, artifact hashes to {:x?}", + &committed[..4], &artifact.artifact_hash[..4])); + } + } self.revoc_cache.lock().unwrap().insert(trust.origin.clone(), artifact); result } @@ -621,7 +632,12 @@ impl Verifier { let cascade = crate::cascade::Cascade::decode(&casc_bytes) .map_err(|e| anyhow!("revocation artifact: cascade decode: {e}"))?; - Ok(CachedRevocation { cascade, tree_size }) + let artifact_hash = { + use sha2::{Sha256, Digest}; + let h = Sha256::digest(text.as_bytes()); + let mut arr = [0u8; 32]; arr.copy_from_slice(&h); arr + }; + Ok(CachedRevocation { cascade, tree_size, artifact_hash }) } } diff --git a/ts/sdk/src/issuer.ts b/ts/sdk/src/issuer.ts index 08484b6..bae7f31 100644 --- a/ts/sdk/src/issuer.ts +++ b/ts/sdk/src/issuer.ts @@ -88,6 +88,11 @@ interface SignedCheckpoint { cosigs: WitnessCosig[]; } +// Process-wide registry — each origin string must be unique per process. +const _registeredOrigins = new Set(); +/** @internal For testing only — deregisters an origin from the global registry. */ +export function _deregisterOrigin(origin: string): void { _registeredOrigins.delete(origin); } + export class Issuer { private readonly origin: string; private readonly originId: bigint; @@ -108,6 +113,10 @@ export class Issuer { private initialized: boolean = false; constructor(config: IssuerConfig, signer: Signer) { + if (!config.origin) throw new Error("Issuer: origin must not be empty"); + if (_registeredOrigins.has(config.origin)) + throw new Error(`Issuer: origin "${config.origin}" is already registered — each origin must be unique`); + _registeredOrigins.add(config.origin); this.origin = config.origin; this.originId = computeOriginId(config.origin); this.schemaId = config.schemaId; @@ -274,11 +283,12 @@ export class Issuer { private async publishCheckpoint(): Promise { const parentRoot = computeRoot(this.batchRoots()); const treeSize = this.totalEntries(); - // Commit revocation state into the checkpoint body so witnesses attest to it. - const revocArt = this.latestRevArtifact; - const body = revocArt + // Build the new artifact FIRST so its hash can be committed in the + // checkpoint body. The checkpoint and artifact are then consistent. + const newRevocArt = await this.buildRevocationArtifact(treeSize); + const body = newRevocArt ? checkpointBodyWithRevoc(this.origin, BigInt(treeSize), parentRoot, - new TextEncoder().encode(revocArt)) + new TextEncoder().encode(newRevocArt)) : checkpointBody(this.origin, BigInt(treeSize), parentRoot); const issuerSig = await this.signer.sign(body); const ts = BigInt(Math.floor(Date.now() / 1000)); @@ -289,7 +299,7 @@ export class Issuer { return { keyId: w.keyId, timestamp: ts, signature: s64 }; }); this.latestCkpt = { treeSize, rootHash: parentRoot, body, issuerSig, cosigs }; - this.latestRevArtifact = await this.buildRevocationArtifact(treeSize); + this.latestRevArtifact = newRevocArt; } private async buildRevocationArtifact(treeSize: number): Promise { diff --git a/ts/sdk/src/verifier.ts b/ts/sdk/src/verifier.ts index 84a2845..8ffe1e5 100644 --- a/ts/sdk/src/verifier.ts +++ b/ts/sdk/src/verifier.ts @@ -17,6 +17,7 @@ */ import { TrustConfig } from "./trust.js"; +import { createHash } from 'node:crypto'; import { decodePayload, MODE_ONLINE } from "./payload.js"; import { decodeTbs, ENTRY_TYPE_DATA } from "./cbor.js"; import { entryHash, verifyInclusion, computeRootFromProof } from "./merkle.js"; @@ -86,7 +87,7 @@ export class Verifier { private readonly revocationProvider: RevocationProvider | undefined; private static readonly MAX_CACHE_ENTRIES = 1000; private readonly cache = new Map(); - private readonly revocCache = new Map(); + private readonly revocCache = new Map(); /** * @param trust Trust configuration from the issuer. @@ -165,7 +166,7 @@ export class Verifier { if (!emb.ok) return fail("embedded checkpoint", emb.reason); add("embedded checkpoint", true, `issuer sig ✓ · ${trust0.witnessQuorum}/${trust0.witnessQuorum} witnesses ✓`); - return this.runAfterRootHash(p, emb.rootHash, trust0, steps, add, fail, ok); + return this.runAfterRootHash(p, emb.rootHash, trust0, steps, add, fail, ok, null); } // 3. Reject null entry (index 0 is reserved). @@ -200,6 +201,7 @@ export class Verifier { // 6. Checkpoint resolution. const cacheKey = `${trust.origin}:${p.treeSize}`; let rootHash: Uint8Array; + let fetchedRevocHash: string | null = null; const cached = this.cache.get(cacheKey); if (cached) { const age = Math.floor((Date.now() - cached.fetchedAt) / 1000); @@ -210,7 +212,7 @@ export class Verifier { let fetched: Uint8Array; let fetchedSize: bigint; try { - [fetched, fetchedSize] = await this.fetchAndVerifyCheckpoint(p.treeSize, trust); + [fetched, fetchedSize, fetchedRevocHash] = await this.fetchAndVerifyCheckpoint(p.treeSize, trust); } catch (e) { return fail("checkpoint fetch", String(e)); } @@ -225,7 +227,7 @@ export class Verifier { } // 7. Entry hash. - return this.runAfterRootHash(p, rootHash, trust, steps, add, fail, ok); + return this.runAfterRootHash(p, rootHash, trust, steps, add, fail, ok, fetchedRevocHash ?? null); } private async runAfterRootHash( @@ -236,6 +238,7 @@ export class Verifier { add: (name: string, ok: boolean, detail: string) => void, fail: (step: string, reason: string) => VerifyTraceResult, ok: (result: VerifyOk) => VerifyTraceResult, + revocHashHex: string | null = null, ): Promise { // 7. Entry hash. const eHash = entryHash(p.tbs); @@ -295,7 +298,7 @@ export class Verifier { add("cbor decode", true, `schema_id=${entry.schemaId} issued=${entry.times[0]} expires=${entry.times[1]}`); // 10. Revocation check — SPEC.md §Revocation. - const revocResult = await this.checkRevocation(p.entryIndex, p.treeSize, trust); + const revocResult = await this.checkRevocation(p.entryIndex, p.treeSize, trust, revocHashHex); if (revocResult.revoked) return fail("revocation", revocResult.reason); add("revocation", true, revocResult.reason); @@ -325,7 +328,7 @@ export class Verifier { private async fetchAndVerifyCheckpoint( requiredSize: bigint, trust: TrustConfig, - ): Promise<[Uint8Array, bigint]> { + ): Promise<[Uint8Array, bigint, string | null]> { let note: string; if (this.noteProvider) { note = await this.noteProvider(trust.checkpointUrl); @@ -340,7 +343,7 @@ export class Verifier { note: string, requiredSize: bigint, trust: TrustConfig, - ): Promise<[Uint8Array, bigint]> { + ): Promise<[Uint8Array, bigint, string | null]> { const blankIdx = note.indexOf("\n\n"); if (blankIdx < 0) throw new Error("note missing blank-line separator"); @@ -401,7 +404,7 @@ export class Verifier { throw new Error(`witness quorum not met: ${verified.size}/${trust.witnessQuorum}`); } - return [rootHash, treeSize]; + return [rootHash, treeSize, _revocHashHex]; } /** Revocation check — SPEC.md §Revocation — Verifier Behavior. */ @@ -445,7 +448,8 @@ export class Verifier { } private async checkRevocation( - entryIndex: bigint, checkpointTreeSize: bigint, trust: TrustConfig + entryIndex: bigint, checkpointTreeSize: bigint, trust: TrustConfig, + committedRevocHash: string | null = null ): Promise<{ revoked: boolean; reason: string }> { const STALE = 32n; // 2 × BATCH_SIZE if (!trust.revocationUrl) @@ -482,6 +486,19 @@ export class Verifier { this.revocCache.set(trust.origin, p); cached = p; } + if (!cached) return { revoked: true, reason: "artifact unavailable (fail-closed)" }; + + // Revoc hash enforcement: reject if the checkpoint committed a revoc hash, + // the served artifact has the same tree_size as the checkpoint (same point + // in time, not a newer artifact), and the hashes differ (tampered artifact). + // Skip if artifact.treeSize > checkpoint.treeSize — artifact was updated + // legitimately (e.g., a revoke happened after the checkpoint was signed). + if (committedRevocHash && + cached.treeSize < checkpointTreeSize && + cached.artifactHash !== committedRevocHash) + return { revoked: true, reason: + `revocation artifact hash mismatch: checkpoint committed ${committedRevocHash.slice(0,16)}… `+ + `but served artifact hashes to ${cached.artifactHash.slice(0,16)}…` }; if (cached.treeSize <= entryIndex) return { revoked: true, reason: `entry ${entryIndex} not covered by artifact (tree_size=${cached.treeSize}) — fail-closed` }; @@ -492,7 +509,7 @@ export class Verifier { private parseRevArtifact( text: string, trust: TrustConfig - ): { cascade: Cascade; treeSize: bigint } | { error: string } { + ): { cascade: Cascade; treeSize: bigint; artifactHash: string } | { error: string } { const parts = text.split("\n\n"); if (parts.length < 2) return { error: "missing blank line" }; const bodyLines = parts[0].split("\n"); @@ -519,7 +536,8 @@ export class Verifier { const pub = trust.issuerPubKey; if (!verifySig(trust.sigAlg, new TextEncoder().encode(body), sigRaw.subarray(4), pub)) return { error: "signature verification failed" }; - return { cascade, treeSize }; + const artifactHash = createHash("sha256").update(new TextEncoder().encode(text)).digest("hex"); + return { cascade, treeSize, artifactHash }; } }