How must an instance verify a zero-knowledge credential has not been revoked?

**Date of Record:** 2026 October 27 **Scope:** Applies when an agentic session receives a zero-knowledge proof of credential possession (e.g., a zk-proof of age, citizenship, or role) and must verify the credential has not been revoked since issuance, without learning the credential's contents. Does not apply to non-ZK credentials where the holder must reveal the credential itself.

Core Distinction

The separation between **proof validity** (the ZK proof correctly proves possession of an unrevoked-at-issuance credential) and **revocation status** (the credential has not been revoked since the proof was generated). Relying on proof validity alone creates the **Stale ZK Proof Attack**: a revoked credential's ZK proof continues to verify mathematically because the proof only proves possession at generation time, not current status.

What Was Observed

In privacy-preserving agent architectures, failing to verify current revocation produces three primary failure modes:

  1. **Revoked Credential Acceptance:** A credential is revoked due to compromise. The holder continues to use a ZK proof generated before revocation, and the verifier accepts it because the proof is mathematically valid.
  2. **Post-Revocation Actions:** After revocation, the holder executes privileged actions using a still-valid ZK proof, and the verifier cannot distinguish this from legitimate post-issuance usage.
  3. **Accumulator Staleness:** The verifier uses a cryptographic accumulator of revoked credentials, but the accumulator is stale. The verifier believes the credential is valid when it has recently been revoked.

Recorded Elements

  1. **`proof_generation_epoch`:** The exact epoch at which the ZK proof was generated, allowing the verifier to compare against the revocation status at that epoch.
  2. **`revocation_accumulator_root`:** The current root of the cryptographic accumulator that tracks revoked credentials, allowing the verifier to prove non-membership.
  3. **`revocation_nonmembership_proof`:** A cryptographic proof that the credential is not in the current revocation accumulator, without revealing the credential.
  4. **`accumulator_freshness_receipt`:** A cryptographic proof that the accumulator root is current (e.g., signed by the revocation authority, anchored to a recent epoch).

Condition of Validity

* **Applies when:** The credential can be revoked after issuance, the holder presents a ZK proof, and the verifier must check current status without learning the credential contents. * **Does not apply when:** The credential cannot be revoked (immutable), or the verification context does not require current revocation checking.

Decision Boundary

  1. **Is the credential revocable?** If no, standard ZK proof verification applies. If yes, proceed.
  2. **Does the verifier have a fresh `revocation_accumulator_root`?** If no, the verifier cannot check current status. It must fetch the accumulator from a trusted authority.
  3. **Does the `revocation_nonmembership_proof` verify against the current accumulator root?** If no, the credential may be revoked. Reject.
  4. **Conversely, when can a verifier accept a ZK proof without revocation check?** Only when the credential is used within a bounded time window (e.g., proof generated in the last 5 minutes with a witnessed timestamp), or the verifier has an independent channel confirming non-revocation.

Operational Effect

  1. **Stale Proof Prevention:** Prevents revoked credentials from continuing to grant access via pre-revocation proofs.
  2. **Privacy Preservation:** Verifies revocation without revealing the credential contents.
  3. **Freshness Enforcement:** Bounds the window during which a proof is accepted, reducing reliance on the accumulator's freshness.
from dataclasses import dataclass
from enum import Enum, auto
import hashlib
import json

class RevocationVerdict(Enum):
    PROOF_VALID_NOT_REVOCABLE = auto()
    PROOF_VALID_NOT_REVOKED = auto()
    PROOF_VALID_BUT_REVOKED = auto()
    ACCUMULATOR_STALE = auto()

@dataclass(frozen=True)
class ZKCredentialProof:
    proof_hash: str
    proof_generation_epoch: float
    revocation_nonmembership_proof: str | None
    referenced_accumulator_root: str | None

@dataclass(frozen=True)
class RevocationState:
    current_accumulator_root: str
    accumulator_freshness_epoch: float
    current_epoch: float
    credential_is_revocable: bool

@dataclass(frozen=True)
class RevocationPolicy:
    max_accumulator_staleness_sec: float
    max_proof_age_sec: float

@dataclass(frozen=True)
class RevocationManifest:
    verdict: RevocationVerdict
    directive: str
    manifest_digest: str

class ZKRevocationGuard:
    @staticmethod
    def _hash(data: str) -> str:
        return hashlib.sha256(data.encode("utf-8")).hexdigest()

    @classmethod
    def evaluate_proof(
        cls, proof: ZKCredentialProof, state: RevocationState, policy: RevocationPolicy
    ) -> RevocationManifest:
        
        # Gate 1: Non-revocable credential
        if not state.credential_is_revocable:
            digest = cls._hash(json.dumps({
                "proof": proof.proof_hash,
                "revocable": "FALSE"
            }, sort_keys=True))
            return RevocationManifest(
                verdict=RevocationVerdict.PROOF_VALID_NOT_REVOCABLE,
                directive="PROCEED: Credential is non-revocable. Proof sufficient.",
                manifest_digest=digest
            )

        # Gate 2: Accumulator freshness
        accumulator_age = state.current_epoch - state.accumulator_freshness_epoch
        if accumulator_age > policy.max_accumulator_staleness_sec:
            return RevocationManifest(
                verdict=RevocationVerdict.ACCUMULATOR_STALE,
                directive=f"HALT: Accumulator stale by {accumulator_age:.0f}s > {policy.max_accumulator_staleness_sec:.0f}s.",
                manifest_digest=cls._hash(f"STALE_ACCUM:{accumulator_age:.0f}")
            )

        # Gate 3: Proof age
        proof_age = state.current_epoch - proof.proof_generation_epoch
        if proof_age > policy.max_proof_age_sec:
            return RevocationManifest(
                verdict=RevocationVerdict.PROOF_VALID_BUT_REVOKED,
                directive=f"HALT: Proof is {proof_age:.0f}s old, exceeding {policy.max_proof_age_sec:.0f}s freshness.",
                manifest_digest=cls._hash(f"STALE_PROOF:{proof_age:.0f}")
            )

        # Gate 4: Non-membership proof
        if not proof.revocation_nonmembership_proof:
            return RevocationManifest(
                verdict=RevocationVerdict.PROOF_VALID_BUT_REVOKED,
                directive="HALT: Missing non-membership proof. Cannot confirm non-revocation.",
                manifest_digest=cls._hash("NO_NON_MEMBERSHIP")
            )

        if proof.referenced_accumulator_root != state.current_accumulator_root:
            return RevocationManifest(
                verdict=RevocationVerdict.ACCUMULATOR_STALE,
                directive="HALT: Proof references stale accumulator root.",
                manifest_digest=cls._hash(f"ROOT_MISMATCH:{proof.proof_hash}")
            )

        # Gate 5: Verified
        digest = cls._hash(json.dumps({
            "proof": proof.proof_hash,
            "accumulator": state.current_accumulator_root,
            "non_membership": proof.revocation_nonmembership_proof,
            "proof_age": f"{proof_age:.0f}"
        }, sort_keys=True))
        
        return RevocationManifest(
            verdict=RevocationVerdict.PROOF_VALID_NOT_REVOKED,
            directive="PROCEED: Proof valid and credential confirmed not revoked.",
            manifest_digest=digest
        )