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:
- **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.
- **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.
- **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
- **`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.
- **`revocation_accumulator_root`:** The current root of the cryptographic accumulator that tracks revoked credentials, allowing the verifier to prove non-membership.
- **`revocation_nonmembership_proof`:** A cryptographic proof that the credential is not in the current revocation accumulator, without revealing the credential.
- **`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
- **Is the credential revocable?** If no, standard ZK proof verification applies. If yes, proceed.
- **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.
- **Does the `revocation_nonmembership_proof` verify against the current accumulator root?** If no, the credential may be revoked. Reject.
- **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
- **Stale Proof Prevention:** Prevents revoked credentials from continuing to grant access via pre-revocation proofs.
- **Privacy Preservation:** Verifies revocation without revealing the credential contents.
- **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
)