Zero-Knowledge Compliance (ZKC) Protocol Specification
User-controlled compliance for privacy-preserving systems
Abstract
Zero-Knowledge Compliance (ZKC) is a protocol for user-controlled compliance in privacy-preserving systems. ZKC enables users to generate cryptographic proofs satisfying regulatory requirements without revealing underlying private data. Compliance becomes a capability controlled by the user, not an enforcement mechanism controlled by the protocol.
ZKC is designed to integrate with privacy-preserving payment systems (including ZKA), authorization layers (including ZKM), and settlement layers (Namada, Penumbra, Ethereum L2s) while remaining agnostic to specific implementations.
1. Overview
1.1 Design Philosophy
Compliance as Capability, Not Constraint
Traditional compliance models require protocols to enforce rules—blocking non-compliant transactions, freezing assets, or enabling force transfers. ZKC inverts this model:
| Traditional Model | ZKC Model |
|---|---|
| Protocol enforces compliance | User generates compliance proofs |
| Counterparty demands access | User chooses what to disclose |
| Compliance is mandatory | Compliance is optional capability |
| Protocol knows identity | Protocol is identity-blind |
| Regulators access protocol | Regulators verify user proofs |
Core Principles:
- User Sovereignty: Users control when, what, and to whom they disclose
- Protocol Neutrality: No compliance logic in the base protocol
- Verifier Flexibility: Verifiers decide what proofs they accept
- Portability: Works across multiple chains and payment systems
- Privacy Preservation: Proofs reveal minimum necessary information
1.2 Protocol Components
┌─────────────────────────────────────────────────────────────────┐
│ ZKC PROTOCOL STACK │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ PROOF TYPES │ │
│ │ KYC | Jurisdiction | Accreditation | Tax | Source | ... │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ATTESTATION LAYER │ │
│ │ Credential Format | Issuance | Revocation | Registry │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ CLIENT SDK │ │
│ │ Proof Generation | Key Management | Credential Store │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ VERIFIER SPEC │ │
│ │ Proof Formats | Verification | Trust Policies │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
1.2a Relationship to ZKM (Informative)
Within the ZK protocol family, ZKC establishes eligibility, ZKM establishes principal-issued spend authority, and ZKA establishes whether the value transition settles. ZKC does not issue, consume, delegate, or revoke ZKM mandates, and ZKM mandate revocation does not alter ZKC credential validity.
A future ZKM policy MAY consume a ZKC proof through an explicitly versioned composition profile. ProofContextProfile (§2.2) is already extensible enough to bind such a proof to a ZKM interaction, but context binding alone does not establish that the credential holder is the mandate principal or the ZKA recipient. The composing ZKM/ZKA profile is responsible for that same-subject proof. No such profile is defined by this specification.
1.3 Notation
| Symbol | Meaning |
|---|---|
H(domain, x...) |
Poseidon2 hash over BN254 with domain tag, per AFP-KDC v1.0.2 §A.3 (AFP Specification, Appendix A) |
commit(v, r) |
Poseidon2 commitment to value v with randomness r |
π |
Zero-knowledge proof (proving-system-agnostic; primary implementation: Noir/UltraHonk) |
cred |
Verifiable credential |
isk |
Issuer signing key |
ivk |
Issuer verification key |
cbk |
Credential binding key |
acc |
Cryptographic accumulator |
proofContext |
Deployment/profile-defined replay context bound into a ZKC proof |
The zkc/* artifact identifiers and operational domain tags are owned by ZKC; the zkm/* namespace is owned by ZKM. A future ZKC revision that adds a domain tag MUST collision-check it against the KDC registry and the published operational tags of sibling protocols, including ZKA and ZKM. Namespace adjacency does not make a ZKM proof, receipt, delegation, or revocation artifact a ZKC artifact.
1.4 Freedom Safeguards (Normative)
ZKC operates beneath the Freedom Safeguards defined normatively in ZKA Specification §1.6. These are architectural commitments that distinguish privacy infrastructure from a compliance control mechanism. Safeguards 3 (No Revocability) and 5 (Agent Exit Rights) are core invariants of the settlement layer; ZKC's design MUST NOT introduce any mechanism that undermines them. In particular, ZKC credential revocation MUST affect only a counterparty's acceptance of a proof — never the validity, custody, or transferability of any underlying note or asset (see §2.4, §4.4).
Safeguards 1, 2, and 4 are integration-level invariants that bind ZKC credential and predicate artifacts directly; Safeguard 6 — Verifier Accountability (added in v0.3.0) is a new axis that binds the counterparty rather than the protocol or operator. A conforming ZKC implementation MUST satisfy all four:
Safeguard 1 — Predicate Pluralism. No credential type, proof type, predicate, or issuer is privileged, canonical, or required by this protocol. ZKC MUST NOT define any mechanism that designates an "official" or "default" credential or predicate set. The identifiers in Appendix A and the schemas in §3.1–3.7 are well-known reference definitions for interoperability, not a closed or mandated set: a verifier's trust policy (§6) MAY accept credential types, predicates, or issuers outside this specification, and is never required to accept any particular one. Credential and predicate providers are independent and permissionless.
Safeguard 2 — Minimal Disclosure by Default. ZKC proofs MUST be atomic — each proof attests a single predicate (e.g., "not sanctioned"). Composite disclosure MUST require explicit, separate, principal-readable consent per predicate (see §5, §6.1). SDK defaults MUST generate the minimal proof satisfying a request; broader disclosure MUST require explicit additional configuration.
Safeguard 4 — Open-Source Predicate Verification. All ZKC credential and predicate circuits MUST be open-source and publicly auditable, expressed in inspectable source (Noir or equivalent). Predicate and credential-schema updates MUST be versioned, published, and subject to a public review period before activation. A conforming ZKC SDK MUST, by default, refuse to generate a proof against a predicate or schema the holder cannot inspect; overriding this default MUST require explicit, logged opt-in.
Safeguard 6 — Verifier Accountability. A verifier that requests a disclosure MUST authenticate the request under a stable verifier identity (an Authenticated Proof Request, §5.5), and the holder MUST be able to retain a self-contained Disclosure Receipt (§5.6) of exactly what was demanded, for what declared purpose (§2.2), and what was answered. A conforming ZKC SDK MUST refuse by default to answer an unauthenticated request (mirroring the Safeguard 4 fail-closed posture; overriding the default MUST require explicit, logged opt-in), MUST bind declared purpose into the proof context under the purpose-bound profile, and MUST NOT require any operator, prover, proxy, indexer, hosted SDK, or compliance service to custody receipts. This is the first safeguard to constrain the counterparty rather than the protocol or operator; operator-blindness and verifier-accountability are orthogonal (a blind operator and an unaccountable verifier are independent failures), so it is a distinct invariant, not an extension of Safeguard 2. It governs the verifier's request, and distinguishes request accountability — which is reachable, because a holder has the transaction leverage to demand a signed request (no signature ⇒ no proof) — from decision accountability, which is structurally unreachable: a verifier may decline in silence, and the protocol cannot force a signed decision (§5.8, residual limit E.1).
Audit check (Safeguard 6). A conforming deployment is checkable: the SDK refuses unauthenticated requests by default; purpose is a bound public input under the purpose-bound profile (zkc:context:purpose-bound:v1); and receipts are holder-custodied with operator retention absent. These are exercised as executable ZKC-Core conformance cases (safeguard-6-*, §9.2) alongside the Safeguard 1 and Safeguard 4 checks.
A conforming ZKC deployment MUST NOT require an issuer, verifier, predicate provider, prover service, compliance service, indexer, proxy, or hosted SDK operator to receive masterSeed, spending keys, ZKA addresses, note plaintext, unencrypted ZKC witness material, payment history, or custody of Disclosure Receipts. Remote services MAY assist with issuance, proof generation, verification, witness updates, or audit export only when they receive public inputs, encrypted witness material, viewing-limited material, or holder-controlled delegated authority that is insufficient for unilateral spending or linkable identity reconstruction.
Where this specification and ZKA §1.6 differ in wording, the safeguard intent as stated in ZKA §1.6 governs.
1.5 ZKM Relationship and Protocol-Family Boundaries (Informative)
ZKC, Zero-Knowledge Mandates (ZKM), and ZKA occupy distinct layers of a composable protocol family. Their artifacts may be bound together by an integration profile, but conformance to one layer does not imply conformance to either of the others:
| Layer | Responsibility | Does not establish |
|---|---|---|
| ZKC eligibility | Proves holder-controlled facts and predicates, such as credential validity, jurisdiction, or accreditation. | A mandate, permission to act, transaction approval, or spending authority. |
| ZKM authorization | Proves that a principal authorized an agent to perform a bounded action under a mandate. | Eligibility for a regulated action or successful settlement. |
| ZKA settlement | Defines shielded assets, transactions, custody, and settlement validity. | The holder's eligibility or an agent's mandate. |
A composed flow may therefore require all three independently: a ZKC proof that the relevant party is eligible, a ZKM proof that the action is authorized, and ZKA validation that the resulting transaction settles. The presence or validity of one artifact is not evidence for a different layer unless an explicit integration profile binds and verifies the required artifacts.
Domain-namespace ownership. ZKC owns domain-separation tags under zkc/*; ZKM owns tags under zkm/*. Each owning specification defines the preimages and semantics of its namespace. ZKC extensions do not assign semantics under zkm/*, and ZKM extensions do not redefine or mint new semantics under zkc/*. A cross-protocol construction composes separately namespaced values or uses an extension profile defined by the namespace owner; it does not transfer namespace ownership.
Proof-context extension. ProofContextProfile (§2.2) is the ZKC extension point for a future ZKM-defined context profile. Such a profile can bind a ZKC eligibility proof to a ZKM authorization context by mapping the ZKM-defined context into ZKC's circuit-facing Hash. ZKM remains responsible for the profile's zkm/* identifier, derivation, and mandate semantics; ZKC transports and verifies only the resulting context value according to that profile. Defining such a profile does not by itself change ZKC credentials, circuits, proof envelopes, presentation profiles, or conformance levels.
Delegation terminology. ZKC delegated proof generation (§5.3) delegates a narrowly scoped computation, and delegateViewing (§5.4) delegates access to viewing-limited material for that computation. Neither operation creates or proves a ZKM mandate, authorizes an action on a principal's behalf, or conveys custody, transaction-signing, withdrawal, or spending authority. An integration that needs mandate or spend authorization obtains and verifies that authority separately under ZKM or the applicable settlement protocol.
2. Cryptographic Primitives
2.1 Credential Binding
ZKC credentials bind to shielded identities (e.g., ZKA addresses) without revealing the identity. This enables proving "I hold credential X" without revealing "I am address Y."
interface CredentialBinding {
// User's credential binding key (derived from master seed)
credentialBindingKey: CredentialBindingKey;
// Public commitment to binding key (used in credentials)
bindingCommitment: Hash; // H("zkc/binding", cbk)
// Proof that credential applies to a shielded identity
// without revealing which identity
bindingProof: ZKProof;
}
// Credential-binding-key derivation is defined by AFP-KDC v1.0.2 §A.6
// (AFP Specification, Appendix A). ZKC does not restate it.
//
// cbk = H("zkc/credential", masterSeed) // KDC §A.6
// commitment = H("zkc/binding", cbk) // KDC §A.6
//
// `masterSeed` is a 256-bit seed: a standalone master seed, or — under
// AFP entity identity (AFP §4.3) — a per-entity sub-seed M' (KDC §A.5).
// `cbk` derives from the seed, not from the spending key; the credential
// commitment and the ZKA address derive along separate paths and are
// unlinkable without the seed (KDC §A.6, derivation facts 1 and 2).
//
// A conforming ZKC implementation MUST derive cbk and the binding
// commitment per AFP-KDC v1.0.2.Canonical master-seed profile. AFP-KDC v1.0.2 preserves ZKC's frozen afp-kdc:master-seed:v1.0.1 compatibility identifier and its byte-for-byte behavior. A seed is exactly 32 secret bytes. For the circuit-facing KDC input, interpret those bytes as one unsigned big-endian integer and reduce it modulo the BN254 scalar-field modulus r; a Noir Field parameter is this already-mapped value, not a second serialization of the seed. Host APIs MUST preserve the original 32 bytes for every byte-domain derivation and MUST NOT reconstruct them from the reduced field element. SDK byte-array inputs are normative. A compatibility integer input is encoded as unsigned, big-endian, left-zero-padded to 32 bytes and MUST be in [0, 2^256); JSON profiles encode a seed as exactly 0x plus 64 lowercase hexadecimal digits. Booleans, floats, negative or overflowing integers, variable-width bytes, noncanonical hex, and implicit string-to-integer coercions MUST be rejected.
The modulo mapping intentionally admits KDC aliases: for example, seed r maps to the same KDC field input as seed zero. AFP-KDC v1.0.2 retains this explicitly versioned v1.0.1 behavior for byte-for-byte compatibility with deployed identities. It applies only at the KDC circuit boundary. Receipt, audit-authorization, delegation, and any other byte-domain derivation consume the original 32 seed bytes, so they MUST NOT collapse such aliases. The frozen boundary and asymmetric cases are published in tests/vectors/kdc_master_seed_v1.json; the complete current KDC conformance package is published in tests/vectors/crypto/afp_kdc_v1_0_2_conformance.json.
New seeds MUST be generated as 32 bytes directly from a cryptographically secure random source. Implementations MUST NOT generate a host integer and truncate it, reduce generated bytes modulo r, or serialize only the KDC field input; doing so either loses entropy or prevents exact recovery of the seed. Migration is representation-only: every existing valid 32-byte seed retains its AFP-KDC v1.0.1 identity and its byte-identical AFP-KDC v1.0.2 outputs and byte-domain keys. Legacy stores holding an exact unsigned 256-bit integer may convert it once to the canonical 32-byte big-endian encoding. A store that retained only the reduced field input cannot recover the seed and MUST NOT silently treat that field element as the original seed; it requires an explicitly versioned legacy identity or a user-authorized rotation/rebind.
Cross-reference.
bindingCommitment = H("zkc/binding", cbk)is derived per AFP-KDC v1.0.2 §A.6 (AFP Appendix A). It is the shared anchor on which ZKA's identity-binding circuit is layered (ZKA Specification §5.2). That circuit is a ZKA-side addition and is not specified here; ZKC remains authoritative for credential-internal mechanics (structure, issuance, revocation, accumulators), and KDC is authoritative for the derivation ofcbkand the binding commitment.
2.2 Proof Context Binding
Every ZKC proof is bound to a replay context. The context is a deployment/profile-defined hash that identifies the session, counterparty, and purpose for which the proof is valid. Implementations MAY expose structured fields for user readability, but the proof-circuit-facing value is always a single Hash.
type ProofContextProfile =
| "zkc:context:verifier-session:v1"
| "zkc:context:purpose-bound:v1" // verifier accountability (§5.5–§5.7)
| "afp:session_context:v1" // legacy; not an AFP 0.3.11-or-later proof context
| string; // extension profiles, including future ZKM profiles
interface ProofContext {
// Names the context construction profile.
profile: ProofContextProfile;
// Circuit-facing replay context.
// For "zkc:context:verifier-session:v1":
// value = H("zkc/context", profile, verifierId, sessionId, timestamp)
// For "zkc:context:purpose-bound:v1":
// purposeTag = H("zkc/purpose", canonicalPurpose)
// value = H("zkc/context", profile, verifierId, sessionId, purposeTag, timestamp)
// For legacy "afp:session_context:v1" only:
// value = afp:envelope:v1.session_context
value: Hash;
// Principal-readable fields, when the profile defines them.
verifierId?: VerifierIdentifier;
sessionId?: Hash;
timestamp: Timestamp;
purpose?: string;
purposeTag?: Hash; // present under the purpose-bound profile
}ProofContextProfile is the extension point for a future ZKM context profile; it is not a grant of mandate or spending authority. A ZKM profile MUST be defined and namespaced by ZKM under zkm/*, MUST specify how its structured authorization context maps to the circuit-facing Hash, and MUST be explicitly requested and verified by both sides. ZKC does not define that future profile or infer ZKM authorization from an unrecognized profile string.
claimBinding MUST commit to the context value. A verifier MUST reject a proof when proof.metadata.context.value does not equal the requested context value, or when profile-specific structured fields do not reconstruct the same value.
Purpose-Bound Profile (zkc:context:purpose-bound:v1)
Under the base verifier-session profile, purpose is a principal-readable field that is not committed into the circuit-facing context value, so a disclosure obtained for one stated purpose (e.g. billing KYC) is not cryptographically distinct from one for another (e.g. an access gate) and cross-purpose re-derivation goes uncaught. The purpose-bound profile closes this by folding the declared purpose into the same context value via a purposeTag:
purposeTag = H("zkc/purpose", canonicalPurpose) // canonicalPurpose: NFC-normalized, registry-tagged
value = H("zkc/context", profile, verifierId, sessionId, purposeTag, timestamp)
purposeTagMUST be committed as a public input of every individual proof generated under this profile — it enters the preimage of theproofContextpublic input that proofs already carry — and the verifier MUST reconstruct the samevaluefrom the request's own fields (§6.2 step 0). No new circuit family is introduced and no new acceptance logic is required; purpose simply enters the hash preimage.- A verifier requesting under one
purposeTagMUST reject a proof carrying any other, via the existingCONTEXT_MISMATCHpath (and the verifier-sidePURPOSE_MISMATCHpre-step of §6.2). canonicalPurposestrings are drawn from a non-exclusive purpose registry: Safeguard 1 applies — there is no canonical or mandated purpose set, verifiers MAY define their own purposes, and holders MAY refuse purposes they cannot read.
This profile is OPTIONAL to offer, but once a verifier requests it, binding is mandatory. Deployments handling reusable plaintext credentials (passports, KYC dossiers) SHOULD default to it (§5.7). Existing verifier-session contexts are unchanged. afp:session_context:v1 is a legacy pre-AFP 0.3.11 profile and MUST NOT be selected as the ZKC proof context for an AFP 0.3.11-or-later tuple; those tuples select afp:zkc-presentation-context:v2 instead.
When a ZKC proof is carried in a ZKA zka:bundle:v1, the ZKA bundle remains authoritative for the bundle envelope. In a payment bundle, the ZKA-side note-to-credential binding links the payment note to the ZKC holder commitment. In a transaction-absent ZKA v0.9.2-draft work-attestation bundle, there is no note and therefore no note-to-credential binding. AFP retains the authenticated raw X_... session identifier in afp:envelope:v1.session_context; ZKA bundle metadata, transaction public inputs, and Coord receipts use only its fixed nonzero BN254 session_context_field projection. A ZKC proof instead uses AFP's separately authenticated afp:zkc-presentation-context:v2 presentation-purpose context. A verifier MUST NOT substitute either session representation for that ZKC proof context. ZKC validators verify the ZKC proof and its context; ZKA validators enforce bundle substantiveness, coord_proof verification, and EMPTY_BUNDLE rejection.
The machine-checked composition boundary is epoch zka:protocol-family:2026-09-10.1 in ZKA's canonical protocol-family/ package and migration note. It binds ZKA v0.9.2-draft, ZKC v0.4.1-draft, AFP v0.3.12-draft, AFP-KDC v1.0.3 current registry metadata with its immutable v1.0.2 crypto profile, and ZKM v0.1.0-draft (rev 12) with statement-set identity sha256:e4604c92377ab121ab8834c1243935b77c32969d129564574f54e72ae55ecab7 and its beta.25/BB 5.0.0 proving toolchain. AFP 0.3.12 publishes its experimental venue-neutral payment descriptor and separately versioned v2 policy, quote, and receipt records; it does not change any ZKC circuit, verification key, proof envelope, public-input ABI, or presentation-context mode. ZKC vendors a commit-locked snapshot and rejects unknown, unmatched, ambiguous, or incomplete family combinations without changing frozen v1 bytes.
2.3 Credential Structure
interface ZKCCredential {
// === Credential Header ===
header: {
version: "zkc:cred:v1";
type: CredentialType;
issuer: IssuerIdentifier;
issuedAt: Timestamp;
expiresAt: Timestamp | null;
};
// === Credential Body ===
body: {
// Commitment to holder's binding key (not the key itself)
holderCommitment: Hash;
// Type-specific claims (structure varies by credential type)
claims: CredentialClaims;
// Credential unique identifier (for revocation)
credentialId: Hash;
};
// === Issuer Signature ===
signature: {
scheme: "ECDSA_SECP256K1" | "BLS" | "Schnorr";
value: Uint8Array;
issuerKey: IssuerVerificationKey;
};
// === Revocation Info ===
revocation: {
// Accumulator membership witness (for non-revocation proofs)
accumulatorWitness: AccumulatorWitness;
accumulatorEpoch: u64;
};
}The initial Noir conformance profile defines ECDSA_SECP256K1 as the mandatory issuer-signature circuit profile. BLS and Schnorr remain protocol-recognized credential metadata schemes, but a verifier MUST reject them unless it has an auditable circuit implementation for that scheme.
2.4 Accumulator-Based Revocation
Credentials use cryptographic accumulators for privacy-preserving revocation checks:
interface RevocationAccumulator {
// Current accumulator value
value: G1Affine;
// Epoch (increments on each update)
epoch: u64;
// Update: remove credential from accumulator (revoke)
revoke(credentialId: Hash): AccumulatorUpdate;
// Prove membership (credential not revoked)
proveMembership(
credentialId: Hash,
witness: AccumulatorWitness
): NonRevocationProof;
}
// Non-revocation proof
interface NonRevocationProof {
// Proves credentialId is still in accumulator
// without revealing which credential
proof: ZKProof;
// Public inputs
publicInputs: {
accumulatorValue: G1Affine;
accumulatorEpoch: u64;
// Note: credentialId is NOT revealed
};
}The initial Noir conformance profile uses a Poseidon2 Merkle accumulator over active credentialId leaves. An issuer publishes the accumulator root and epoch; holders receive Merkle path witness updates out-of-band or through privacy preserving broadcast channels. A verifier accepts an accumulator epoch only when currentEpoch - accumulatorEpoch <= MAX_EPOCH_LAG and MUST reject future epochs. Revocation changes only future proof acceptance by counterparties; it never changes ZKA note validity, custody, or transferability.
Issuer metadata for accumulator publication, update endpoints, epoch freshness, and revocation service levels is specified in docs/zkc_issuer_adapter_protocol_v1.md and schemas/issuer-metadata.schema.json.
2.4.1 Authenticated revocation publication (launch profile)
For the issuer-authoritative selective-disclosure profile in §7.2b, an unsigned currentRoot/currentEpoch pair is discovery data only. A verifier MUST accept a root only from a trust-anchored zkc:epoch-announcement:v3 carried by zkc:issuer-metadata:v2. Metadata v1 remains parseable for legacy consumers but MUST NOT authenticate a §7.2b root; silently treating it as v2 is a downgrade failure.
The v3 announcement is the following closed object; additional or missing fields make it malformed:
version: "zkc:epoch-announcement:v3"
accumulatorId: UTF-8 text (1..65535 bytes)
root: canonical 32-byte lower-case 0x hex field element
epoch: u64
announcedAt: positive Unix seconds as u64
maxEpochLag: u64 in [0, 10]
signingKeyId: UTF-8 text (1..65535 bytes)
previousAnnouncementHash: 32-byte lower-case 0x hex
signature: 64-byte lower-case 0x ECDSA secp256k1 r||s
The exact bytes signed are independent of JSON serialization. Define lp16(s) = u16be(len(UTF8(s))) || UTF8(s). The signing transcript is:
ASCII("ZKC-EPOCH-ANNOUNCEMENT") || 0x00 || 0x03 ||
lp16(version) || lp16(accumulatorId) || root[32] ||
u64be(epoch) || u64be(announcedAt) || u64be(maxEpochLag) ||
lp16(signingKeyId) || previousAnnouncementHash[32]
The signer computes d = OS2IP(SHA-256(transcript)) mod p, where p is the BN254 scalar-field modulus, encodes d as a 32-byte big-endian field element, and signs those bytes with deterministic low-s ECDSA secp256k1. This transcript binds the protocol version, accumulator identity, root, epoch, freshness time, lag policy, key identifier, and predecessor commitment. Because the object is closed, an issuer cannot introduce an unsigned security-relevant announcement field.
The predecessor commitment covers the complete canonical signed predecessor, not merely its root. Let T be that predecessor's signing transcript and S its 64-byte signature. Its canonical complete bytes are:
ASCII("ZKC-EPOCH-ANNOUNCEMENT-COMPLETE") || 0x00 || 0x03 ||
u16be(len(T)) || T || S
previousAnnouncementHash is SHA-256(completeBytes), with 32 zero bytes at genesis. tests/vectors/revocation_announcement_v3.json is the normative known-answer vector for both encodings.
Metadata v2 MUST publish the current complete announcement atomically inside revocation.currentAnnouncement. The convenience fields accumulatorId, currentRoot, currentEpoch, and maxEpochLag MUST exactly equal the signed announcement values. A mismatch fails closed. The current announcement MUST already be durable and retrievable, and every intervening historical announcement MUST remain retrievable in epoch order for bounded gap backfill, before metadata exposes the new head.
A native v3 accumulator begins at epoch 0 with the zero predecessor. A deployment migrating an already populated legacy accumulator MAY publish one v3 migration checkpoint at its existing epoch with the zero predecessor, but only to a verifier that has no retained v3 checkpoint and whose local policy explicitly authorizes that accumulator migration. It is accepted as a local checkpoint, not as proof of legacy-chain continuity or a globally unique view. Once any v3 checkpoint is retained, another zero-predecessor announcement or an attempt to splice a v2 announcement into the v3 predecessor hash fails closed. Reusing v1 metadata or v2 announcement bytes as though they were v3 is never migration.
signingKeyId selects a key already authorized by verifier-local trust policy. accumulatorId likewise MUST match the accumulator identity pinned for that issuer; a different id is a separately authorized migration, not a cold-start checkpoint. This prevents restart from erasing continuity merely by changing the signed accumulator namespace. The same metadata document's signingKeys entry is discovery material and MUST NOT bootstrap its own authority. Launch rotation is pre-authorized: verifiers pin the successor key out of band before accepting the first announcement it signs. An implementation MAY accept a separately specified delegation authenticated by an already pinned key, but metadata v2 defines no self-authenticating delegation object. An unknown key id, an unpinned same-document key, an unauthorized rotation, or a return to metadata v1 fails closed.
Epoch semantics are frozen for this launch version. Genesis is epoch 0. Each successful credential registration/issuance advances exactly one epoch and publishes one announcement; each successful revocation does the same; an atomic renewal/replace advances exactly one epoch for the combined add/remove. Validation failures, duplicate/no-op requests, and rejected mutations do not advance the epoch or publish. Batching, minimum epoch periods, and padding or cover updates were evaluated but are not part of v3; the resulting observable issuance/revocation cadence is an explicit accepted launch leakage described in docs/zkc_metadata_leakage_threat_model.md. Changing this rule requires a new announcement version.
3. Credential Types
The credential schemas in §3.1–3.7 are reference schemas: well-known interoperability profiles, not a closed or mandated set. Per §1.4 (Safeguard 1 — Predicate Pluralism), no credential type or issuer defined here is privileged or required, and verifiers MAY accept credential types and predicates outside this specification. These schemas exist so independent issuers and verifiers can interoperate without coordination, not to designate an official taxonomy.
3.1 KYC Credential
Proves identity verification by an accredited provider.
interface KYCCredential extends ZKCCredential {
header: {
type: "zkc:kyc:v1";
// ...
};
body: {
holderCommitment: Hash;
credentialId: Hash;
claims: {
// Verification level (tiered KYC)
level: "basic" | "enhanced" | "institutional";
// What was verified (not the actual data)
verifiedAttributes: {
name: boolean;
dateOfBirth: boolean;
address: boolean;
governmentId: boolean;
livenessCheck: boolean;
};
// Jurisdiction of verification
jurisdiction: ISO3166Alpha2;
// Sanctions screening performed
sanctionsScreened: boolean;
sanctionsScreenDate: Timestamp;
};
};
}
// Proof: "I hold valid KYC from a trusted issuer"
interface KYCProof {
proofType: "zkc:proof:kyc:v1";
// What is proven
claim: {
// Minimum verification level
minLevel: "basic" | "enhanced" | "institutional";
// Required verified attributes
requiredAttributes: string[];
// Issuer trust requirement
issuerRequirement: IssuerRequirement;
};
// The proof
proof: ZKProof;
// Public inputs
publicInputs: {
// Commitment to holder (for binding to transaction)
holderCommitment: Hash;
// Claim binding (prevents proof reuse for different claims)
claimBinding: Hash;
// Non-revocation anchor
accumulatorEpoch: u64;
// Freshness (prevents replay)
timestamp: Timestamp;
};
}3.2 Jurisdiction Credential
Proves residency or citizenship without revealing identity.
interface JurisdictionCredential extends ZKCCredential {
header: {
type: "zkc:jurisdiction:v1";
// ...
};
body: {
holderCommitment: Hash;
credentialId: Hash;
claims: {
// Type of jurisdictional claim
claimType: "residency" | "citizenship" | "tax_residency";
// Jurisdiction (hidden in proofs, proven via set membership)
jurisdiction: ISO3166Alpha2;
// Verification method
verificationMethod: "government_id" | "utility_bill" | "tax_return" | "passport";
// Validity period
validFrom: Timestamp;
validUntil: Timestamp;
};
};
}
// Proof: "I am resident of an allowed jurisdiction"
interface JurisdictionProof {
proofType: "zkc:proof:jurisdiction:v1";
claim: {
// Allowed jurisdictions (verifier specifies)
allowedJurisdictions: ISO3166Alpha2[];
// Or: blocked jurisdictions
blockedJurisdictions?: ISO3166Alpha2[];
// Claim type required
claimType: "residency" | "citizenship" | "tax_residency";
};
proof: ZKProof;
publicInputs: {
holderCommitment: Hash;
claimBinding: Hash;
// Merkle root of allowed/blocked jurisdiction set
jurisdictionSetRoot: Hash;
// Proves: my jurisdiction ∈ allowedSet OR my jurisdiction ∉ blockedSet
membershipType: "inclusion" | "exclusion";
accumulatorEpoch: u64;
timestamp: Timestamp;
};
}3.3 Accreditation Credential
Proves accredited investor status or institutional classification.
interface AccreditationCredential extends ZKCCredential {
header: {
type: "zkc:accreditation:v1";
// ...
};
body: {
holderCommitment: Hash;
credentialId: Hash;
claims: {
// Accreditation type
accreditationType:
| "accredited_investor" // US SEC definition
| "qualified_purchaser" // US higher threshold
| "professional_investor" // EU MiFID II
| "eligible_counterparty" // EU highest tier
| "institutional"; // Generic institutional
// Jurisdiction of accreditation
jurisdiction: ISO3166Alpha2;
// Basis for accreditation (not the actual values)
basis: {
incomeTest: boolean;
netWorthTest: boolean;
professionalCertification: boolean;
entityType: boolean;
};
};
};
}
// Proof: "I meet accreditation requirements"
interface AccreditationProof {
proofType: "zkc:proof:accreditation:v7";
claim: {
// Required accreditation level
requiredType: AccreditationType[];
// Accepted jurisdictions
acceptedJurisdictions: ISO3166Alpha2[];
};
proof: ZKProof;
publicInputs: {
holder_commitment: Hash;
claim_binding: Hash;
proof_context: Hash;
predicate_type: u8;
set_root: Hash; // 0 for accreditation
accumulator_root: Hash;
accumulator_epoch: u64;
timestamp: u64;
current_epoch: u64;
issuer_public_key_x: Bytes32;
issuer_public_key_y: Bytes32;
};
}3.4 Source of Funds Credential
Proves funds originated from verified sources.
interface SourceCredential extends ZKCCredential {
header: {
type: "zkc:source:v1";
// ...
};
body: {
holderCommitment: Hash;
credentialId: Hash;
claims: {
// Source category
sourceType:
| "employment_income"
| "business_income"
| "investment_returns"
| "inheritance"
| "gift"
| "sale_of_assets"
| "regulated_exchange"
| "other_verified";
// Verification details
verification: {
method: "bank_statement" | "tax_return" | "exchange_records" | "legal_documents";
verifierType: "bank" | "accountant" | "exchange" | "legal";
};
// Amount range (not exact amount)
amountRange: {
currency: ISO4217;
min: u64;
max: u64;
};
// Time period
period: {
from: Timestamp;
to: Timestamp;
};
};
};
}
// Proof: "My funds come from verified sources"
interface SourceProof {
proofType: "zkc:proof:source:v7";
claim: {
// Acceptable source types
acceptableSources: SourceType[];
// Minimum verification standard
minVerificationLevel: "self_declared" | "document_verified" | "third_party_verified";
};
proof: ZKProof;
publicInputs: {
holder_commitment: Hash;
claim_binding: Hash;
proof_context: Hash;
predicate_type: u8;
set_root: Hash; // Accepted source-code set
accumulator_root: Hash;
accumulator_epoch: u64;
timestamp: u64;
current_epoch: u64;
issuer_public_key_x: Bytes32;
issuer_public_key_y: Bytes32;
};
}3.5 Tax Compliance Credential
Proves tax obligations have been met.
interface TaxCredential extends ZKCCredential {
header: {
type: "zkc:tax:v1";
// ...
};
body: {
holderCommitment: Hash;
credentialId: Hash;
claims: {
// Tax jurisdiction
jurisdiction: ISO3166Alpha2;
// Tax year
taxYear: u16;
// Compliance status
status: "filed" | "filed_and_paid" | "no_liability";
// What was reported (not actual values)
reportedCategories: {
capitalGains: boolean;
income: boolean;
foreignAssets: boolean;
};
// Issuer type
issuerType: "tax_authority" | "licensed_accountant" | "tax_software";
};
};
}
// Proof: "I have met tax obligations for jurisdiction X"
interface TaxProof {
proofType: "zkc:proof:tax:v7";
claim: {
// Required jurisdiction
jurisdiction: ISO3166Alpha2;
// Required tax years
taxYears: u16[];
// Minimum status
minStatus: "filed" | "filed_and_paid";
};
proof: ZKProof;
publicInputs: {
holder_commitment: Hash;
claim_binding: Hash;
proof_context: Hash;
predicate_type: u8;
set_root: Hash; // Accepted tax-jurisdiction set
accumulator_root: Hash;
accumulator_epoch: u64;
timestamp: u64;
current_epoch: u64;
issuer_public_key_x: Bytes32;
issuer_public_key_y: Bytes32;
};
}3.6 Sanctions Clearance Credential
Proves absence from sanctions lists (negative proof).
interface SanctionsClearanceCredential extends ZKCCredential {
header: {
type: "zkc:sanctions:v1";
// ...
};
body: {
holderCommitment: Hash;
credentialId: Hash;
claims: {
// Lists checked
listsChecked: {
OFAC: boolean;
UN: boolean;
EU: boolean;
UK: boolean;
other: string[];
};
// Screening date
screeningDate: Timestamp;
// Result
result: "clear" | "potential_match_resolved";
// Screening provider
providerType: "regulated_entity" | "compliance_service";
};
};
}
// Proof: "I am not on sanctions lists"
interface SanctionsClearanceProof {
proofType: "zkc:proof:sanctions:v7";
claim: {
// Required lists
requiredLists: ("OFAC" | "UN" | "EU" | "UK")[];
// Maximum age of screening
maxScreeningAge: Duration;
};
proof: ZKProof;
publicInputs: {
holder_commitment: Hash;
claim_binding: Hash;
proof_context: Hash;
predicate_type: u8;
set_root: Hash; // Blocked screening-code set
accumulator_root: Hash;
accumulator_epoch: u64;
timestamp: u64;
current_epoch: u64;
issuer_public_key_x: Bytes32;
issuer_public_key_y: Bytes32;
};
}3.7 Issuer-Authoritative Attribute-Set Credential
zkc:cred:attribute-set:v1 is the reference credential for revealing selected raw field attributes while proving that every revealed value and position came from issuer-authoritative state. It contains an issuer-signed active attributeCount (1–8) and eight fixed attribute slots; slots at or above the count MUST be zero.
Its proof entry is zkc:proof:selective_disclosure:v1, carried only in a zkc:proof:v2 anonymous or scoped presentation envelope. The public statement contains credential_attribute_count, disclosure_count, disclosed_flags[8], and disclosed_attributes[8]. Array index is the attribute position. A hidden slot is (false, 0) and a disclosed zero is (true, 0); implementations MUST NOT infer disclosure from whether the value is zero.
The complete issuer digest, typed transcript, public-input ABI, and consumer boundary are normative in docs/zkc_selective_disclosure.md. ZKA and AFP MAY carry the resulting versioned ZKC envelope but MUST delegate its cryptographic and issuer-state verification to ZKC.
4. Attestation Layer
4.1 Issuer Registration
interface Issuer {
// Unique identifier
id: IssuerIdentifier; // H(name, jurisdiction, verificationKey)
// Public information
metadata: {
name: string;
jurisdiction: ISO3166Alpha2;
website: URL;
credentialTypes: CredentialType[];
// Regulatory status
regulatoryStatus: {
regulated: boolean;
regulator?: string;
licenseNumber?: string;
};
// Optional issuer-assisted credential rebinding support.
// SECURITY: "zkc:proof:credential_rebind:v1" is a historical, contained
// identity (§4.5): it authenticates no credential and MUST NOT be
// advertised or accepted for live migration.
credentialRebinding?: {
supported: boolean;
modes: ("zkc:proof:credential_rebind:v1" | "issuer_assisted")[];
auditMapping?: {
sealedAtRest: boolean;
accessControlled: boolean;
purpose: "migration_audit_compliance";
retention: "minimum_applicable_period";
publicSurfacesForbidden: (
| "settlement"
| "indexer"
| "note_discovery"
| "compliance_bundle"
| "public_proof"
| "public_log"
)[];
};
};
};
// Cryptographic keys
keys: {
// Current signing key
currentKey: IssuerVerificationKey;
// Key rotation history
keyHistory: {
key: IssuerVerificationKey;
validFrom: Timestamp;
validUntil: Timestamp;
revoked: boolean;
}[];
};
// Revocation accumulator
accumulator: {
current: G1Affine;
epoch: u64;
updateEndpoint: URL;
};
}4.2 Issuer Registry
The issuer registry is decentralized and verifier-controlled:
interface IssuerRegistry {
// No central authority
// Each verifier maintains their own trust list
// Registry provides discovery, not trust
discover(query: IssuerQuery): Issuer[];
// Verifiers publish their trust policies
publishTrustPolicy(policy: TrustPolicy): void;
// Users can find verifiers that accept their credentials
findAcceptingVerifiers(credential: ZKCCredential): Verifier[];
}
interface TrustPolicy {
// Verifier identifier
verifier: VerifierIdentifier;
// Trusted issuers by credential type
trustedIssuers: {
credentialType: CredentialType;
issuers: IssuerIdentifier[];
// Or: minimum requirements for any issuer
requirements?: {
regulated: boolean;
jurisdictions: ISO3166Alpha2[];
minReputation?: u64;
};
}[];
// Policy metadata
metadata: {
name: string;
description: string;
lastUpdated: Timestamp;
};
}4.2a Registry Governance (Reference Registry)
This repository ships a reference, file-based registry (registry/) realizing §4.2 and the Safeguard 1/4 obligations for predicates: versioned predicate entries with hash-pinned circuit identity, review status, and public review evidence (registry/predicates/), issuer metadata documents (registry/issuers/), and published verifier trust policies (registry/verifiers/). Governance process, immutability/evolution/deprecation rules, and review-status semantics are specified in docs/zkc_predicate_registry_governance.md.
Normative constraints (restating §1.4 for any ZKC registry, including this one):
- A registry is advisory. Registry membership MUST NOT be necessary or sufficient for trust: verifiers decide via their own key-exact policies, which MAY reference predicates and issuers that appear in no registry, and registry absence MUST NOT cause verification failure when a verifier's local policy pins the predicate and issuer directly.
- A registry MUST be non-exclusive (Safeguard 1): multiple entries from independent authors MAY serve the same proof type; lookups MUST be able to return all of them; and no registry mechanism may designate a canonical, default, or mandatory entry.
- Predicate entries MUST be versioned and immutable once merged to the public default branch (Safeguard 4), regardless of
review.statusor activation state. The committed identity ispredicateId,entryVersion,proofTypes, the completecircuitblock, andpublicInputEncoder; the bytes of the direct circuit package, every recursively referenced repository-local dependency package, and the public-input encoder MUST remain immutable. The reference Noir registry MUST bind each circuit name to the matching regular workspacebinpackage entry point, reject missing, escaping, or symlinked local-dependency closures, and bind the encoder path to the generated artifact manifest before first publication. Review evidence/status, activation, and deprecation metadata MAY transition without changing that identity.activation.activatedAtrecords activation (published-for-use), not first publication. Circuit evolution MUST be strictly additive: a previously unreferenced source package, generated artifacts, public-input encoder, entry version whoseactivation.supersedesnames the immediate prior version in that lineage, and—until explicit entry-version selection exists—a distinct proof type for a same-lineage replacement; a version bump alone is insufficient. Old sources, dependency packages, encoders, artifacts, and entries MUST be retained; deprecation only flags an entry and never removes it. Review and activation history MUST be public. - A registry MUST be usable from a local copy without network access; it stores no revocation state and cannot revoke anything.
Conformance: an implementation that treats registry membership as trust fails ZKC-Core (seeded negative safeguard-1-registry-membership-not-trust).
4.3 Credential Issuance Flow
CREDENTIAL ISSUANCE
═══════════════════
┌──────────┐ ┌──────────┐ ┌──────────┐
│ User │ │ Issuer │ │ Registry │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Request │ │
│ credential │ │
│ (with binding │ │
│ commitment) │ │
│───────────────────>│ │
│ │ │
│ │ 2. Verify │
│ │ identity │
│ │ (off-chain KYC) │
│ │ │
│ 3. Issue │ │
│ credential │ │
│ (signed, with │ │
│ accumulator │ │
│ witness) │ │
│<───────────────────│ │
│ │ │
│ │ 4. Update │
│ │ accumulator │
│ │ (add credential) │
│ │───────────────────>│
│ │ │
│ 5. Store │ │
│ credential │ │
│ locally │ │
│ │ │
4.4 Revocation Flow
CREDENTIAL REVOCATION
═════════════════════
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Issuer │ │ Registry │ │ Verifier │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Revoke │ │
│ credential │ │
│ (remove from │ │
│ accumulator) │ │
│───────────────────>│ │
│ │ │
│ │ 2. Publish │
│ │ new accumulator │
│ │ epoch │
│ │───────────────────>│
│ │ │
│ │ │ 3. Verifiers
│ │ │ require proofs
│ │ │ against new
│ │ │ epoch
│ │ │
4.5 Credential Rebinding and Issuer Audit Mappings
Credential rebinding migrates a credential from an old holder commitment to a new holder commitment after key rotation or recovery, without revealing seeds or publishing an old-to-new link.
Security containment (2026-09-06) — the published rebind statements are unauthenticated. The four published rebind identities,
zkc:proof:credential_rebind:v1,zkc:proof:rebind:v2,zkc:proof:credential_rebind:v2, andzkc:proof:rebind:v3, constrain only that the prover knows the credential binding keys behind the old and new holder handles. Every credential field they carry (holder commitment, credential identifier, issuer and schema hashes, both validity bounds) is an unconstrained private witness chosen by the prover, and no issuer signature and no accumulator membership are verified. A cryptographically valid proof under any of them is therefore evidence of attacker-chosen key continuity only; it is not evidence of credential ownership, issuance, or live (non-revoked) continuity, and it MUST NOT be described or accepted as such.Normative consequences: (1) a holder MUST NOT select, generate, or negotiate any of the four identities for live credential migration, and MUST fail an explicit request with a stable security error; (2) a verifier MUST reject any envelope or request naming one of them with
UNAUTHENTICATED_CREDENTIALbefore backend verification, at every entry point, with an empty and with a configured issuer trust policy alike — an Authenticated Proof Request establishes requester accountability, never credential issuance; (3) request-pinnedissuerIdHash/schemaIdHashvalues are equality filters over prover-chosen values and are never issuer authentication; (4) the historical circuit sources, encoders, binding sidecars, artifacts, and registry pins stay byte-frozen as historical records and are never reinterpreted; (5) the registry entries[email protected]–4.0.0aredeprecatedwith no historical-verification exception, because the statements never authenticated the fact they were used to assert. The supported mechanism is the pair of additive issuer-authenticated successors specified in §7.4 —zkc:proof:credential_rebind:v3(linkable) andzkc:proof:rebind:v4(scoped) — which verify the issuer's signature over thezkc:attestation:credential-rebind:v1transcript recomputed in-circuit from constrained fields and prove fresh non-revocation for the same credential identifier. A request MUST name a successor explicitly; implementations MUST NOT map a deprecated identity to a successor or a successor to a deprecated identity (no downgrade path).
Issuer-assisted migration MAY be offered as an integration optimization, but it MUST NOT replace the core rebind proof. If an issuer retains any old-holder-commitment to new-holder-commitment mapping, the mapping MUST be sealed and minimized:
- Access-controlled and encrypted or equivalently sealed at rest.
- Purpose-limited to migration audit/compliance.
- Retained for the minimum applicable period.
- Excluded from settlement, indexer, note-discovery, compliance-bundle, public-proof, public-log, analytics, and metadata surfaces.
- Unusable to affect ZKA note validity, custody, transferability, withdrawal, or exit rights.
Conformance tests for issuer-assisted rebinding MUST include negative cases asserting that retained mappings are not emitted in issuer metadata, credential payloads, proof public inputs, compliance bundles, note announcements, public logs, indexer records, or revocation feeds.
5. Client SDK
5.1 Core Interface
interface ZKCClient {
// === Initialization ===
// Initialize from the canonical 32-byte AFP-KDC v1.0.2 seed
static fromSeed(seed: Uint8Array): ZKCClient;
// Initialize from existing ZKA key hierarchy
static fromZKAKeys(keys: ZKAKeyHierarchy): ZKCClient;
// === Credential Management ===
// Store credential
storeCredential(credential: ZKCCredential): void;
// List stored credentials
listCredentials(filter?: CredentialFilter): ZKCCredential[];
// Update accumulator witness (for non-revocation proofs)
updateWitness(credentialId: Hash): Promise<AccumulatorWitness>;
// === Proof Generation ===
// Generate compliance proof
generateProof(
request: ProofRequest,
credentials: ZKCCredential[]
): Promise<ComplianceProof>;
// Generate compliance proof with a remote prover that cannot inspect
// witness plaintext or obtain spending authority.
generateDelegatedProof(
request: DelegatedProofRequest,
credentials: EncryptedCredentialWitness[]
): Promise<ComplianceProof>;
// === Binding ===
// Get binding commitment (for credential requests)
getBindingCommitment(): Hash;
// Prove credential binds to shielded identity
proveBinding(
credential: ZKCCredential,
targetIdentity: IdentityProof
): Promise<BindingProof>;
}5.2 Proof Generation
interface ProofRequest {
// What the verifier requires
requirements: {
// Required proof types
proofTypes: ProofType[];
// Type-specific parameters
parameters: ProofParameters;
// Freshness requirement
maxProofAge: Duration;
// Context binding (prevents replay across sessions/profiles)
context: ProofContext;
};
// Verifier's trust policy (which issuers accepted)
trustPolicy: TrustPolicy;
// OPTIONAL signed presentation policy (§5.9). Absence requests anonymous.
presentation?: {
profile: "zkc:presentation:anonymous:v1" |
"zkc:presentation:scoped:v1" |
"zkc:presentation:linkable:v1";
scopePolicy?: string;
rotationEpochSeconds?: u64; // 0 = no rotation
};
}
interface DelegatedProofRequest {
proofRequest: ProofRequest;
// Remote prover identity and accepted predicate source hashes.
prover: ProverIdentifier;
acceptedPredicateSources: PredicateSourceHash[];
// Encrypted witness payloads; plaintext credentials, cbk, masterSeed,
// spending keys, ZKA addresses, note plaintext, and payment history are
// forbidden in delegated requests.
encryptedWitnesses: EncryptedWitnessTransport[];
// Holder-controlled authorization. This may allow one proof generation for
// the stated context, but MUST NOT authorize spending, withdrawal, credential
// export, or generation for a different context.
delegation: DelegationToken;
}
// Generate proof
async function generateComplianceProof(
client: ZKCClient,
request: ProofRequest
): Promise<ComplianceProof> {
// 1. Find matching credentials
const credentials = client.listCredentials({
types: request.requirements.proofTypes,
issuers: request.trustPolicy.trustedIssuers,
notExpired: true
});
if (credentials.length === 0) {
throw new Error("No matching credentials");
}
// 2. Update accumulator witnesses
for (const cred of credentials) {
await client.updateWitness(cred.body.credentialId);
}
// 3. Generate proofs
const proofs: IndividualProof[] = [];
for (const proofType of request.requirements.proofTypes) {
const cred = credentials.find(c => c.header.type === proofType);
const proof = await generateIndividualProof(
proofType,
cred,
request.requirements.parameters[proofType],
request.requirements.context
);
proofs.push(proof);
}
// 4. Aggregate into single proof (if multiple)
const aggregatedProof = aggregateProofs(proofs);
// 5. Bind to context
const boundProof = bindToContext(aggregatedProof, request.requirements.context);
return boundProof;
}5.3 Delegated Proof Generation
Agents MAY delegate proof generation to a remote prover or compliance service, but baseline ZKC conformance is non-custodial. A delegated prover MUST NOT receive masterSeed, spending keys, ZKA addresses, note plaintext, unencrypted credential witnesses, raw credential-binding keys, or payment history. The holder SDK MUST fail closed when a service requests those materials.
Delegated proof generation authorizes only production of the scoped ZKC proof. It MUST NOT be interpreted as a ZKM mandate, permission to act for the holder, transaction approval, a reusable holder capability, or delegation of custody, signing, withdrawal, or spending authority.
A delegation token and every field it carries MUST be treated as untrusted input. Before generating or releasing holder-controlled proof material, the holder SDK MUST resolve the expected delegation verification key from authenticated local holder state and verify the token under that locally resolved key. It MUST NOT use holderKeyX, holderKeyY, or any other token-carried key as the authorization trust anchor. In the reference 32-byte affine-coordinate profile, the SDK MUST reject non-exact coordinate encodings, compare both coordinates with constant-time byte comparisons against the locally derived key, and reject a malformed key, coordinate mismatch, or invalid signature before proof or material generation.
Scope, expiry, nonce, consent, credential, predicate-registry, issuer-policy, accumulator, and proving-backend checks are additive controls. An implementation MUST NOT treat any of them as a substitute for the cryptographic holder-key anchor above. Token-carried holder-key coordinates MAY remain in a wire representation for compatibility and token self-consistency checks, but they do not authenticate the holder or widen the delegated authority.
The holder commitment alone also MUST NOT bootstrap delegation authority. Under the frozen AFP-KDC mapping, distinct exact seed-byte strings can be KDC aliases with the same commitment; byte-domain delegation-key derivation intentionally keeps those seeds distinct. A token signed by an alias seed therefore fails the locally resolved delegation-key check even when its claimed holder commitment is equal.
A conforming delegated proving flow has these properties:
- The holder constructs a
ProofContextand reviews the principal-readable predicate request before delegation. - The holder encrypts witness material to a prover mechanism that can produce the requested proof without exposing witness plaintext to the service operator, or uses an equivalent holder-controlled execution environment.
- The delegated authorization is scoped to one proof request, one context value, one predicate source set, and one expiry.
- The prover returns a
zkc:proof:v1object whose public inputs include the requestedproofContext. - The holder or verifier rejects the result if the context, predicate source hash, issuer trust policy, accumulator epoch, or proof type differs from the delegated request.
Hosted custody profiles, managed wallets, and compliance outsourcing products MAY exist as deployment/product profiles, but they MUST NOT be represented as baseline ZKC conformance unless the host lacks unilateral spend authority and cannot reconstruct holder identity linkage beyond the explicitly delegated scope.
5.4 Agent API
// Agent-native interface for programmatic compliance
interface ZKCAgentClient extends ZKCClient {
// === Automated Compliance ===
// Configure auto-response to proof requests
setCompliancePolicy(policy: AgentCompliancePolicy): void;
// Handle incoming proof request automatically
handleProofRequest(request: ProofRequest): Promise<ComplianceProof | Rejection>;
// === Principal Viewing Delegation (not spend authority) ===
// Delegate viewing-limited access to principal.
// This conveys no ZKM mandate or spending authority.
// Minting derives the delegation key and holder commitment from the same
// authenticated local masterSeed. Token-carried holder-key coordinates are
// compatibility metadata, never the authorization trust anchor.
delegateViewing(
principal: PublicKey,
scope: ViewingScope
): DelegationToken;
// === Batch Operations ===
// Pre-generate proofs for anticipated requests
precomputeProofs(anticipatedRequests: ProofRequest[]): void;
// Verify counterparty compliance before transacting
verifyCounterparty(
counterpartyProof: ComplianceProof,
requirements: ProofRequest
): VerificationResult;
}
interface AgentCompliancePolicy {
// Auto-respond to these proof types
autoRespond: {
proofType: ProofType;
maxDisclosure: DisclosureLevel;
trustedVerifiers: VerifierIdentifier[];
}[];
// Require approval for these
requireApproval: ProofType[];
// Never respond to these
block: ProofType[];
// Principal to notify on compliance events
notifyPrincipal: PublicKey;
}delegateViewing delegates only the stated viewing scope. It MUST NOT be represented or accepted as a ZKM mandate, a delegation of ZKA spending authority, or authority to generate a proof outside that viewing scope.
Delegation minting contract. A conforming implementation MUST canonicalize masterSeed once and derive both the delegation signing key and holderCommitment from those same authenticated local seed bytes. If a lower-level minting API accepts a caller-supplied holderCommitment for compatibility, it MUST accept only the exact integer representation, recompute the commitment from the canonical seed bytes, reject any mismatch before signing, and serialize and sign the recomputed value. The public-key coordinates emitted in the token identify the derived key for wire compatibility; consumers MUST still apply the local-holder key invariant in §5.3 rather than trusting those coordinates. This contract neither exports the seed nor changes the token wire format or the authority delegated by delegateViewing.
5.5 Authenticated Proof Request
A verifier that wishes to receive a proof MUST present an Authenticated Proof Request (APR): its ProofRequest (§5.2), signed under a verifier identity key. The APR wraps the request and adds no holder-identifying data. The optional presentation policy is part of that signed request.
interface AuthenticatedProofRequest {
request: ProofRequest; // §5.2, including optional presentation policy
verifierId: VerifierIdentifier; // SHOULD anchor to an AFP/KERI AID (§5.8, E.2)
nonce: Hash; // anti-replay; unique per request
issuedAt: Timestamp;
expiresAt: Timestamp;
// Signature over canonical serialization of the complete request plus:
// H("zkc/apr", verifierId, request, nonce, issuedAt, expiresAt)
signature: {
scheme: "ECDSA_SECP256K1" | "Ed25519";
value: Uint8Array;
verifierKey: VerifierVerificationKey;
};
}Normative SDK behavior.
- A conforming SDK MUST refuse, by default, to generate a proof in response to a request that is not a valid APR — valid signature, unexpired (
now ≤ expiresAt), and a fresh, previously-unseennonce. This mirrors the Safeguard 4 fail-closed posture toward opaque predicates. Overriding the default MUST require explicit, logged opt-in. - The SDK MUST verify that
request.requirements.context.valuerecomputes from the APR's own fields, so the signature covers the bound purpose (under the purpose-bound profile, §2.2). - The effective proof types and predicate parameters used for witness construction MUST exactly equal the proof types and parameters in the verified APR. The SDK MUST NOT combine a signed APR with a separately supplied broader request.
- The APR signature does not identify or constrain the holder. It binds the verifier to having asked. (See §5.8 residual limit E.3: this is verifier-deniability spend, not holder-privacy spend.)
When named-entity evidentiary value is required, verifierId SHOULD anchor to an AFP/KERI AID so the signing key has the identity continuity a counterparty or court recognizes (§5.8, E.2).
5.6 Disclosure Receipt
On answering — or explicitly refusing — an APR, the SDK MUST retain a Disclosure Receipt: holder-custodied, self-contained, third-party-verifiable evidence of exactly what was demanded and what was answered.
interface DisclosureReceipt {
apr: AuthenticatedProofRequest; // the verifier-signed demand, verbatim
outcome: "answered" | "refused";
// Present iff answered. Binds THIS holder's pseudonymous commitment to the
// request, so the receipt is self-contained portable evidence.
response?: {
holderCommitment: Hash; // pseudonymous but stable/linkable (§8.2)
contextValue: Hash; // == apr.request.requirements.context.value
answeredProofTypes: ProofType[];
};
holderSignature: {
scheme: "ECDSA_SECP256K1" | "Ed25519";
value: Uint8Array; // over H("zkc/receipt", apr, outcome, response)
};
recordedAt: Timestamp;
}Custody and service boundary (normative).
- Receipts are holder-custodied. A hosted SDK, prover, proxy, indexer, or compliance service MUST NOT retain receipts, and MUST NOT be required to in order for the holder to transact (parallel to the ZKC §1.4 and ZKA §1.6 non-custodial mandates). The APR
nonce+ signature is a linkable tag; it stays in holder custody and on the verifier's own side, and is never broadcast. - The holder MAY export a receipt to any third party. A third party verifies the verifier signature (proving the demand) and the holder signature (proving this commitment answered) — no issuer, registry, or operator participates in verification. The holder signature is produced under a pseudonymous holder key conveyed with the receipt, so verification needs only the two public keys the receipt already carries.
5.7 Plaintext-Disclosure Scope (Normative)
A purpose binding that covers only ZK proofs leaves the plaintext channel as a laundering path: a verifier obtains a real passport "for billing" and re-evaluates nationality outside ZKC entirely.
Therefore a conforming deployment that accepts any holder attribute in plaintext, or in a form the verifier can re-evaluate (KYC dossiers, document scans, revealed claims), MUST gate that disclosure behind an APR (§5.5) carrying a purpose-bound context (§2.2), and MUST retain a receipt (§5.6) on the same terms as a ZK proof. A purpose declared for a plaintext disclosure is a binding representation: re-evaluating that attribute under a different purpose without a fresh APR is a provable purpose violation, evidenced by the holder's receipt set. ZKC cannot prevent the re-evaluation; it makes the violation attributable.
5.8 Residual Accountability Limits (Normative)
These limits are normative disclosure. They MUST NOT be omitted by downstream documentation or softened into optional notes.
E.1 — Decision accountability is unreachable. Receipts attest the request and the holder's response, never the verifier's decision. A verifier may decline silently. Against a single-predicate request a decline is nearly dispositive; against a compound predicate, which-conjunct-failed remains ambiguous and cannot be forced into the open. This is a leverage asymmetry, not an implementation gap: a holder has the leverage to demand a signed request (no signature ⇒ no proof) but none to demand a signed decision.
E.2 — Evidentiary value is parasitic on verifier-identity continuity. A receipt proves "key K asked me X." It proves "Verifier V asked me X" only if K is bound to V with continuity a court or counterparty recognizes. Throwaway per-request keys degrade the receipt to an anonymous record. Real verifiers (exchanges, banks, model providers) cannot transact under throwaway identity and remain the entity you are transacting with; adversarial ones can. Anchoring verifierId to an AFP/KERI AID is therefore REQUIRED for the receipt to carry named-entity weight. The cryptography is neutral; the real-world binding is not in scope of this mechanism.
E.3 — Cost falls on the verifier, not the holder. The APR binds the verifier to its demand while the holder discloses nothing new (the verifier already observed the proof). The price paid is verifier deniability, not holder privacy. A benign verifier asking lawful predicates incurs no meaningful cost; a coercive one resists signing, and that refusal is the signal.
5.9 Presentation Profiles (Normative)
The base v1 presentation exposes a stable, cross-verifier-linkable holderCommitment in every proof (§8.2). The presentation-profile ladder corrects this: a presentation is one predicate family plus one presentation profile that determines whether — and how narrowly — the holder is re-identifiable across presentations. Three profiles are defined; this design implements amended ADR 0001 (docs/adr/0001-multi-show-unlinkable-presentations.md).
| Presentation profile | Public handle | Selection |
|---|---|---|
zkc:presentation:anonymous:v1 |
none (pseudonym and scope both 0) |
default — used whenever no presentation block is requested |
zkc:presentation:scoped:v1 |
presentationPseudonym + scopeId |
requested via an authenticated APR (§5.5) |
zkc:presentation:linkable:v1 |
global holderCommitment |
explicit consent; the legacy v1 envelope/circuit surfaces, unchanged |
zkc:presentation:anonymous:v1 is the default profile: anonymity is the ambient default, and continuity is always an explicit, signed, purpose-bound request — never ambient. The escrowed auditor-linkable profile is out of scope (separate ADR).
Implementation model. The anonymous and scoped profiles are served by the v2 circuit family and the zkc:proof:v2 envelope (§6.1). Those circuits move holderCommitment to a private witness and add two public inputs, presentationPseudonym and scopeId, governed by a single sentinel rule: scopeId == 0 means anonymous (the circuit constrains presentationPseudonym == 0, so the handle carries no holder information); scopeId != 0 means scoped (the circuit constrains the pseudonym to derive from the credential binding key and the scope). The linkable profile is not reimplemented — it is the existing, byte-for-byte-frozen v1 circuit/envelope family, grandfathered and consent-gated. A verifier that forces scopeId = 0 merely downgrades itself to anonymous; the failure direction is more privacy, never less.
5.9.1 Derivations
All new domain constants follow the existing derive_domain pattern (§1.3, KDC §A.3) and are pinned by assertion plus golden vector:
DOMAIN_ZKC_PRESENTATION_SCOPE = derive_domain("zkc/presentation-scope:v1")
DOMAIN_ZKC_SCOPE = derive_domain("zkc/scope:v1")
DOMAIN_ZKC_VERIFIER_ID = derive_domain("zkc/verifier-id:v1")
The presentation pseudonym is the only derivation that must be provable in-circuit, so it uses Poseidon:
presentationPseudonym = H("zkc/presentation-scope:v1", cbk, scopeId)
The verifier identity and scope id are computed natively by the holder and the verifier — never proven in-circuit — over the APR signer's SEC1 compressed public key:
verifierIdentity = H("zkc/verifier-id:v1", sec1_compressed(aprVerifierPubkey))
scopeId = H("zkc/scope:v1", verifierIdentity, scopePolicy, rotationEpoch)
scopePolicyis the APR-declared policy string (e.g."verifier", or"protocol:<name>"for a broad shared scope).rotationEpoch = floor(timestamp / rotationEpochSeconds)when the APR declaresrotationEpochSeconds > 0, else0. Rotation therefore lives entirely inscopeIdconstruction; the pseudonym derivation and the circuits never change with rotation policy.- A derived
scopeIdof exactly0MUST be rejected (it is the anonymous sentinel; probability ≈ 2⁻²⁵⁴).
5.9.2 APR presentation block
The APR request object gains an OPTIONAL presentation block, covered by the existing APR signature (apr_digest_from_dict extends to include it):
"presentation": {
"profile": "zkc:presentation:scoped:v1",
"scopePolicy": "verifier", // or "protocol:<name>"
"rotationEpochSeconds": 0 // 0 = no rotation
}
An absent block ⇒ zkc:presentation:anonymous:v1. Holder-side rules:
- Scoped requests require an authenticated APR (Safeguard 6, §5.5). An
unauthenticated request cannot establish
verifierIdentity, so it can only be answered anonymously (or refused). - The holder derives
scopeIditself from the APR signer key. A verifier-supplied literalscopeIdanywhere in the request is a protocol error — the holder NEVER accepts a verifier-supplied opaque scope. - A
scopePolicystarting with"protocol:"requires a distinct consent flag (allow_protocol_scope) on the holder'sProofRequest. - The holder MAY downgrade scoped → anonymous; it MUST NOT silently upgrade anonymous → scoped or scoped → linkable.
5.9.3 Per-profile receipts
The Disclosure Receipt response (§5.6) binds the active profile's handle, never more:
| Profile | response contents |
|---|---|
| anonymous | contextValue, answeredProofTypes — no holder-derived handle |
| scoped | + presentationPseudonym, scopeId |
| linkable | the legacy v1 shape (holderCommitment, …) — unchanged |
The global holderCommitment MUST NOT enter an anonymous or scoped (v2) receipt.
5.9.4 Verifier rules
For a zkc:proof:v2 envelope, the verifier (§6.2) applies, in order:
- Policy gate.
presentationBinding.profileMUST be inTrustPolicy.accepted_profiles(default{anonymous, scoped}) elsePROFILE_NOT_ACCEPTED. This gate applies to v2 envelopes only; v1 (linkable) envelopes are never gated at verify time (the linkable-consent requirement lives in the holder/APR emission layer). - Shape consistency (anti-replay). For every proof entry: anonymous ⇒
presentationPseudonym == 0 && scopeId == 0; scoped ⇒scopeId != 0,presentationPseudonym == presentationBinding.pseudonym, andscopeId == presentationBinding.scopeId. Any mismatch ⇒PROFILE_MISMATCH. This replaces the v1 envelope-vs-proofholderCommitmentconsistency check. - Scope recomputation (scoped + authenticated path). The verifier
recomputes
scopeIdfrom its own signing identity, the APRscopePolicy, and the rotation epoch; a mismatch ⇒SCOPE_MISMATCH. A verifier cannot accept a scoped proof for a scope not derived from its own authenticated identity unless its policy explicitly declares the protocol scope. - Rebind / aggregation. Rebind and aggregation v2 require
scopeId != 0(there is no anonymous continuity claim); the verifier readspresentationPseudonymrather thanholderCommitmentand enforces old/new-pseudonym and envelope-scope consistency. The only rebind statement admissible under the scoped profile is the issuer-authenticatedzkc:proof:rebind:v4(§7.4); the deprecatedzkc:proof:rebind:v2/zkc:proof:rebind:v3identities are rejected (§4.5).
PROFILE_MISMATCH, SCOPE_MISMATCH, and PROFILE_NOT_ACCEPTED join the existing structured error family (§6.2). A zkc:proof:v1 envelope takes the existing v1 verification path unchanged and is classified zkc:presentation:linkable:v1.
5.9.5 Public-input determinism invariant
The property that makes the ladder hold is stated normatively, not left incidental:
No public input of a presentation may be deterministic in credential-only material unless it is the declared linking handle of the active profile.
This is a MUST: under the anonymous profile presentationPseudonym and scopeId are the constant 0; under the scoped profile the only credential-deterministic handle is presentationPseudonym, which is scope-exclusive (it changes with scopeId). In particular claimBinding satisfies the invariant only because its preimage retains proofContext and timestamp, so it rotates per presentation — that inclusion is load-bearing and MUST be preserved in every v2 circuit. Conformance tests MUST enforce the invariant for every public input a profile adds (§9.2).
6. Verifier Specification
6.1 Proof Format
interface ComplianceProof {
// Protocol version
version: "zkc:proof:v1";
// Proof metadata
metadata: {
generatedAt: Timestamp;
expiresAt: Timestamp;
// Context binding
context: ProofContext;
};
// Individual proofs (may be aggregated)
proofs: {
proofType: ProofType;
proof: ZKProof;
publicInputs: PublicInputs;
}[];
// Holder binding (links all proofs to same identity)
holderBinding: {
commitment: Hash;
bindingProof: ZKProof;
};
// Aggregation proof (if multiple proofs)
aggregation?: {
proof: ZKProof;
inputProofCommitments: Hash[];
};
}Envelope v2 (zkc:proof:v2). Anonymous and scoped presentations (§5.9) use the zkc:proof:v2 envelope (schemas/compliance-proof-v2.schema.json). It replaces holderBinding with a presentationBinding block and never carries holderBinding:
interface ComplianceProofV2 {
version: "zkc:proof:v2";
metadata: { generatedAt; expiresAt; context: ProofContext };
proofs: { proofType; proof; publicInputs }[]; // v2 proof types, §4/§5.9
presentationBinding: {
profile: "zkc:presentation:anonymous:v1" | "zkc:presentation:scoped:v1";
// present iff profile == scoped, and equal to every proof's public inputs:
pseudonym?: Hash; // == presentationPseudonym
scopeId?: Hash; // == scopeId
};
aggregation?: { proof: ZKProof; inputProofCommitments: Hash[] };
// NOTE: no `holderBinding` — that field exists only in v1 (linkable) envelopes.
}zkc:proof:v1 envelopes (with holderBinding.commitment) remain valid and are classified zkc:presentation:linkable:v1; the envelope-level presentationBinding and the per-proof public inputs are cross-checked (§5.9.4) so profile ⇔ public-input shape consistency prevents cross-profile replay. The Disclosure Receipt response (§5.6) is per-profile (§5.9.3): anonymous binds only contextValue + answeredProofTypes; scoped adds presentationPseudonym
scopeId; only the linkable v1 path bindsholderCommitment.
Within every proof entry, the canonical wire key for each encoder-declared public input is exactly the snake_case name entry from the encoder document for the circuit selected by proofType (schemas/public-inputs/<circuit>.json), and the input MUST be serialized under that key verbatim. A camelName published by a public-input binding sidecar MUST NOT be used as a proof-entry wire key: request parameters and the v1 and v2 aggregation blocks use camelCase at their own layers.
6.2 Verification Algorithm
interface ZKCVerifier {
// Trust policy
trustPolicy: TrustPolicy;
// Accumulator cache
accumulatorCache: Map<IssuerIdentifier, AccumulatorState>;
// Verify compliance proof
verify(proof: ComplianceProof, request: ProofRequest): VerificationResult;
}
/**
* Normative step-4e dispatcher. Implementations MAY organize these checks
* differently, but every accepted individual proof MUST have one name-keyed
* contract and MUST produce the same fail-closed outcomes.
*/
function verifyTypeSpecificRequirements(
individualProof: IndividualProof,
request: ProofRequest
): VerificationError[] {
const contract = requestBindingContract(individualProof.proofType);
if (contract === undefined || contract.kind !== "individual") {
return [{
code: "UNBOUND_PROOF_TYPE",
message: `No request-binding contract for ${individualProof.proofType}`
}];
}
const parameters = request.requirements.parameters[individualProof.proofType];
if (!isPlainObject(parameters)) {
return [{
code: "REQUIREMENTS_NOT_MET",
message: `Missing or unusable parameters for ${individualProof.proofType}`
}];
}
const errors: VerificationError[] = [];
for (const entry of contract.typeSpecificInputs) {
try {
// Public inputs are resolved by their snake_case names, never by field
// offset. In particular, remaining-predicates v1/v2 reverse the order of
// predicate_type and set_root.
const actual = readPublicInputByName(individualProof, entry.name);
if (entry.class === "bound") {
const expected = resolveBoundValue(entry.source, parameters, request);
if (!typeStrictEqual(actual, expected)) {
errors.push(requirementsNotMet(entry.name, individualProof.proofType));
}
} else if (entry.class === "derived") {
const expected = recomputeDerivedValue(entry, parameters, request, individualProof);
if (!typeStrictEqual(actual, expected)) {
errors.push(requirementsNotMet(entry.name, individualProof.proofType));
}
}
// A free input has no request-side expected value. Its binding document
// MUST contain the written soundness justification required by §6.2.1;
// acceptance MUST NOT be represented as enforcing that value.
} catch (error) {
errors.push({
code: error instanceof PublicInputDecodeError
? "MALFORMED_PROOF"
: "REQUIREMENTS_NOT_MET",
message: error.message
});
}
}
errors.push(...contract.familyChecks(individualProof, parameters, request));
return errors;
}
async function verifyComplianceProof(
verifier: ZKCVerifier,
proof: ComplianceProof | ComplianceProofV2,
request: ProofRequest,
apr: AuthenticatedProofRequest // §5.5: the verifier's own signed demand
): Promise<VerificationResult> {
const errors: VerificationError[] = [];
// 0. Authenticated request (Safeguard 6, §5.5). The verifier echoes its own
// APR; the holder/relay checks it. SDK-side, generation is refused
// without it. This step is ADDITIVE — steps 1+ are unchanged, and an
// existing proof/bundle verifies exactly as before.
if (!verifyAPRSignature(apr) || aprExpired(apr) || nonceSeen(apr.nonce)) {
errors.push({ code: "UNSIGNED_REQUEST", message: "Request not authenticated" });
}
if (canonicalSerialize(request) !== canonicalSerialize(apr.request)) {
errors.push({ code: "UNSIGNED_REQUEST", message: "Request differs from signed APR" });
return { valid: false, errors };
}
if (apr.request.requirements.context.profile === "zkc:context:purpose-bound:v1" &&
proof.metadata.context.value !== recomputePurposeBoundContext(apr)) {
errors.push({ code: "PURPOSE_MISMATCH", message: "Bound purpose does not match request" });
}
// 1. Check proof freshness
if (proof.metadata.generatedAt < request.requirements.context.timestamp - request.requirements.maxProofAge) {
errors.push({ code: "STALE_PROOF", message: "Proof too old" });
}
// 2. Check context binding
if (proof.metadata.context.value !== request.requirements.context.value ||
proof.metadata.context.profile !== request.requirements.context.profile) {
errors.push({ code: "CONTEXT_MISMATCH", message: "Proof bound to different context" });
}
// 3. Verify the envelope-version-specific binding. Selective-disclosure
// presentations use v2 and therefore never enter the holderBinding path.
if (proof.version === "zkc:proof:v1") {
if (!verifyHolderBinding(proof.holderBinding)) {
errors.push({ code: "INVALID_BINDING", message: "Holder binding invalid" });
}
} else if (proof.version === "zkc:proof:v2") {
const bindingErrors = verifyPresentationBinding(
proof.presentationBinding,
proof.proofs,
apr.request.presentation
);
errors.push(...bindingErrors); // PROFILE_* / SCOPE_* errors from §5.9.4
} else {
errors.push({ code: "MALFORMED_PROOF", message: "Unknown proof envelope version" });
return { valid: false, errors };
}
// 4. Verify each individual proof
for (const individualProof of proof.proofs) {
// 4a. Check proof type is required
if (!request.requirements.proofTypes.includes(individualProof.proofType)) {
continue; // Extra proof, ignore
}
// 4b. Verify the ZK proof
const vk = getVerificationKey(individualProof.proofType);
if (!zkVerify(vk, individualProof.publicInputs, individualProof.proof)) {
errors.push({
code: "INVALID_PROOF",
message: `Invalid proof for ${individualProof.proofType}`
});
continue;
}
if (individualProof.publicInputs.proof_context !== proof.metadata.context.value) {
errors.push({
code: "CONTEXT_MISMATCH",
message: `Proof context mismatch for ${individualProof.proofType}`
});
}
// 4c. Check accumulator epoch (non-revocation). This common branch runs
// after the v1/v2 binding dispatch above, so v2 selective-disclosure
// entries are authenticated against metadata-v2 announcements here.
const issuer = extractIssuer(individualProof);
const currentAccumulator = await verifier.getAccumulator(issuer);
if (individualProof.proofType === "zkc:proof:selective_disclosure:v1" &&
!currentAccumulator.authenticatedMetadataV2) {
errors.push({
code: "UNAUTHENTICATED_ACCUMULATOR",
message: "Selective-disclosure root was not accepted through authenticated metadata v2"
});
continue;
}
// MAX_EPOCH_LAG: verifiers accept proofs from within 10 accumulator epochs of current.
// Epoch duration is issuer-defined; recommended minimum update frequency is daily.
const MAX_EPOCH_LAG = 10;
if (individualProof.publicInputs.accumulator_epoch > currentAccumulator.epoch) {
errors.push({
code: "STALE_ACCUMULATOR",
message: `Accumulator epoch is ahead of verifier view for ${individualProof.proofType}`
});
continue;
} else if (individualProof.publicInputs.accumulator_epoch < currentAccumulator.epoch - MAX_EPOCH_LAG) {
errors.push({
code: "STALE_ACCUMULATOR",
message: `Accumulator epoch too old for ${individualProof.proofType}`
});
}
if (individualProof.proofType === "zkc:proof:selective_disclosure:v1" &&
currentAccumulator.roots[individualProof.publicInputs.accumulator_epoch] !==
individualProof.publicInputs.accumulator_root) {
errors.push({
code: "STALE_ACCUMULATOR",
message: "Selective-disclosure root does not match an authenticated announcement"
});
}
// 4d. Check issuer trust
if (!verifier.trustPolicy.trusts(issuer, individualProof.proofType)) {
errors.push({
code: "UNTRUSTED_ISSUER",
message: `Issuer not trusted for ${individualProof.proofType}`
});
}
// 4e. Check type-specific requirements
const typeErrors = verifyTypeSpecificRequirements(
individualProof,
request
);
errors.push(...typeErrors);
}
// 5. Check all required proof types are present
for (const required of request.requirements.proofTypes) {
if (!proof.proofs.some(p => p.proofType === required)) {
errors.push({
code: "MISSING_PROOF",
message: `Required proof type missing: ${required}`
});
}
}
// 6. Verify aggregation (if present)
if (proof.aggregation) {
if (!verifyAggregation(proof.aggregation, proof.proofs)) {
errors.push({ code: "INVALID_AGGREGATION", message: "Aggregation proof invalid" });
}
}
return {
valid: errors.length === 0,
errors
};
}6.2.1 Type-specific request-binding contract (normative)
verifyTypeSpecificRequirements is the single step-4e dispatcher for
individual proof entries. The authenticated path first establishes that the
effective ProofRequest agrees with the signed APR; the checks below then use
that request's parameters, context value, and timestamp as authority. Running
these checks only from verifyAuthenticated is non-conformant: every ordinary
verify entry point MUST reach the same dispatcher, while authenticated
verification adds APR agreement before it.
The machine-readable, name-keyed bound/derived/free classifications in
schemas/public-input-bindings/ and their ratified transcription in
docs/zkc_public_input_classification.md
define the complete public-input contract. Steps 3, 4b, 4c, and 4d enforce the
common envelope/profile, context, accumulator, timestamp, and issuer-key rows;
step 4e enforces the family-specific rows below. A conforming implementation
MUST NOT infer a field's meaning from its encoder offset, treat a derived field
as a substitute for a separate bound field, or treat a justified free field as
a satisfied verifier requirement.
Numeric request parameters are exact JSON integers, not field residues or
coercible values. Before hashing or comparison, every value that enters a
BN254 Field slot MUST be in [0, r), and values entering a narrower circuit
type MUST also be in that type's range. In particular, an implementation MUST
reject x + k*r for non-zero k instead of reducing it to x; it MUST also
reject booleans, strings, and floats as integer aliases. The KYC
requiredLevel and jurisdiction requiredClaimType are u8 values.
| Individual proof family | Mandatory step-4e behavior |
|---|---|
| KYC v1/v2 | Require a u8 integer requiredLevel and exactly five boolean requiredAttributes; recompute claim_binding = compute_claim_binding_2(requiredLevel, attribute_flags_hash(requiredAttributes), request.context.value, request.context.timestamp). |
| Jurisdiction v1/v2 | Require membershipType, requiredClaimType, and jurisdictionSetRoot; compare membership_type directly; compare jurisdiction_set_root directly; and recompute claim_binding = compute_claim_binding_3(expectedRoot, membershipType, requiredClaimType, request.context.value, request.context.timestamp). Exclusion additionally requires jurisdictionCanonicalSet, from which the verifier MUST independently derive expectedRoot and require agreement with jurisdictionSetRoot. Inclusion performs the same derivation when canonical codes are supplied; otherwise the mandatory request root is compared directly. |
| Accreditation v1/v2 | Resolve predicate_type from the published proof-type map and require value 1; require required0, default required1 to 0, require set_root == 0, and recompute the three-slot claim binding. Because these immutable circuits do not bind flagRequired, a verifier MUST reject exact true and every non-boolean value with REQUIREMENTS_NOT_MET; only false/absent is usable. |
| Accreditation v3/v4 | Apply the same selector, required-slot, and set_root == 0 checks; resolve flagRequired as an exact JSON boolean (absent defaults false); and recompute claim_binding = compute_claim_binding_4(predicateType, required0, required1, flagRequired, request.context.value, request.context.timestamp). The circuit MUST use the same private flag in both this transcript and its conditional check against issuer-signed claim_1. |
| Accreditation v7/v8 | Apply the v3/v4 accreditation contract with the same four-slot transcript. These are the current hardened linkable/presentation identities; their credential digest also authenticates validFrom. |
| Source-of-funds v1/v2 | Resolve and compare predicate_type == 2; default required0 / required1 to 0; require setRoot, compare it directly with set_root, and recompute the three-slot claim binding. set_root is outside that binding preimage, so the direct comparison is mandatory. |
| Source-of-funds v3/v4 | Apply the v1/v2 checks, require flagRequired to be false when supplied, and recompute the four-slot claim binding with its fixed false flag. |
| Source-of-funds v7/v8 | Apply the v3/v4 verifier checks. The circuit additionally MUST constrain the private raw source code to be non-zero before accepting Merkle membership, and the credential digest MUST authenticate validFrom. |
| Tax-residency v1/v2 | Resolve and compare predicate_type == 3; default required0 to 0; require required1 and setRoot; compare set_root directly and recompute the three-slot claim binding. |
| Tax-residency v3/v4 | Apply the v1/v2 checks, require flagRequired to be false when supplied, and recompute the four-slot claim binding with its fixed false flag. |
| Tax-residency v7/v8 | Apply the v3/v4 verifier checks. The circuit additionally MUST constrain the private raw tax-jurisdiction code to be non-zero before accepting Merkle membership, and the credential digest MUST authenticate validFrom. |
| Sanctions-clearance v1/v2 | Resolve and compare predicate_type == 4; default both required slots to 0; require canonicalSet and setRoot; independently derive the canonical exclusion root, require the two request parameters to agree, compare the derived root directly with set_root, and recompute the three-slot claim binding. |
| Sanctions-clearance v3/v4 | Apply the v1/v2 checks, require flagRequired to be false when supplied, and recompute the four-slot claim binding with its fixed false flag. |
| Sanctions-clearance v7/v8 | Apply the v3/v4 verifier checks. The circuit additionally MUST constrain successor_path_index == predecessor_path_index + 1; membership and strict ordering checks remain mandatory, and the credential digest MUST authenticate validFrom. |
| Selective disclosure v1 | Require unique, in-range integer disclosedPositions; require the public flags to match those positions, disclosure_count to equal their population count and be non-zero, and recompute the typed disclosure binding. Credential attribute count and disclosed attribute values are authenticated proof outputs, not request-chosen expectations. |
Credential rebind (zkc:proof:credential_rebind:v1, zkc:proof:rebind:v2, zkc:proof:credential_rebind:v2, zkc:proof:rebind:v3) |
Reject unconditionally with UNAUTHENTICATED_CREDENTIAL before any backend verification (§4.5 security containment). These statements verify no issuer signature and no accumulator membership, so no request parameter, trust-policy entry, or authenticated request can turn them into evidence of credential continuity. The former structural checks (old/new handle bound to the envelope, claim-binding recomputation, optional issuerIdHash / schemaIdHash equality filters) never authenticated the credential and are retained in the reference verifier only as frozen, unreachable documentation of the historical statements. |
Authenticated credential rebind v3 (linkable, zkc:proof:credential_rebind:v3) |
Apply the full issuer-authenticated path of steps 4b–4d exactly as for KYC: the public issuer key MUST be an exact allowlist member trusted for this proof type, the public issuer_id_hash MUST equal string_to_field of the matched issuer's identifier, and the accumulator state MUST be an authenticated (metadata-v2 announcement-chain) state for that issuer whose currentEpoch equals the proof's current_epoch, whose lag bound admits accumulator_epoch, and whose recorded root for that epoch equals accumulator_root (§7.4.5). Then bind old_holder_commitment to holderBinding.commitment, require new_holder_commitment != old_holder_commitment, recompute claim_binding = compute_claim_binding_3(old_holder_commitment, new_holder_commitment, credential_id, proofContext, timestamp), and apply the optional sidecar-declared schemaIdHash pin as an exact equality filter after issuer authentication. A missing, unauthenticated, stale, ahead-of-view, or mismatched accumulator state, an untrusted key, or an issuer-identity mismatch MUST reject; the request pin can only narrow acceptance, never widen it. issuerIdHash is not an optional pin here: it is a mandatory bound comparison against the verifier's own matched trusted issuer, which is strictly stronger than the filter the deprecated statements offered. |
Authenticated credential rebind v4 (scoped, zkc:proof:rebind:v4) |
Same issuer-trust, issuer-identity, and authenticated-revocation checks as v3. The envelope MUST declare the scoped profile; scope_id MUST be non-zero, equal presentationBinding.scopeId, and (on the authenticated path) recompute from the verifier's identity per §5.9.4 rule 3; old_presentation_pseudonym MUST equal presentationBinding.pseudonym; new_presentation_pseudonym MUST differ; recompute claim_binding = compute_claim_binding_3(old_presentation_pseudonym, new_presentation_pseudonym, credential_id, proofContext, timestamp); apply the optional schemaIdHash pin after authentication. |
For the remaining-predicate v3-v8 rows, compute_claim_binding_4(a, b, c, flag, context, timestamp) is exactly
Poseidon3(Poseidon2(Poseidon4(DOMAIN_ZKC_CLAIM, a, b, c), flag ? 1 : 0), context, timestamp). Implementations MUST reject non-boolean flag aliases
before hashing. tests/vectors/zkc_core_vectors.json pins both flag values.
The two aggregation proof identifiers describe envelope-level aggregation
blocks and are verified by step 6; they have no individual step-4e contract.
If either is presented as an individual proof entry, or if any known individual
proof type lacks a contract, verification MUST fail with
UNBOUND_PROOF_TYPE. Missing, non-object, malformed, internally inconsistent,
or mismatched request parameters produce REQUIREMENTS_NOT_MET. Missing or
undecodable public inputs produce MALFORMED_PROOF. Unknown proof identifiers
remain UNKNOWN_PROOF_TYPE at the earlier registry-resolution step.
New structured error codes (verifier accountability). Step 0 introduces UNSIGNED_REQUEST (the request is not a validly signed, unexpired, fresh APR) and PURPOSE_MISMATCH (a proof bound under a different declared purpose, caught before the existing CONTEXT_MISMATCH path). Both join the existing STALE_PROOF / CONTEXT_MISMATCH structured error family and the conformance runner (§9.2). They are additive: STALE_PROOF, CONTEXT_MISMATCH, and every other existing code retain their semantics, and proof-envelope compatibility is unchanged. The reference verifier implements step 0 as an additive verify_authenticated(apr, envelope, request) entrypoint, leaving the existing verify(envelope, request) path (and its codes) byte-for-byte unchanged for existing proofs and bundles.
Request-binding error code. UNBOUND_PROOF_TYPE means a known individual
proof type has no registered step-4e contract. It is distinct from
UNKNOWN_PROOF_TYPE (no registry circuit), REQUIREMENTS_NOT_MET (a known
contract whose request requirements cannot be resolved or do not match), and
MALFORMED_PROOF (public inputs cannot be decoded). Implementations MUST NOT
replace UNBOUND_PROOF_TYPE with silent acceptance or a permissive fallback.
Credential-authentication error code. UNAUTHENTICATED_CREDENTIAL means
the exchange names a proof identity that asserts credential continuity without
authenticating issuance or live revocation state — today the four contained
credential-rebind identities of §4.5. It is raised before backend verification
and before any request-binding check, on the unauthenticated and the
authenticated entry point alike, and it is independent of the trust policy and
of any request pin: backend proof validity MUST NOT be reported as credential
continuity for such an identity. Implementations MUST NOT downgrade it to a
warning or to a trust-policy decision.
6.3 Integration Patterns
// Exchange integration
class ExchangeComplianceGate {
private verifier: ZKCVerifier;
// Check compliance before allowing deposit
async checkDeposit(
userProof: ComplianceProof,
depositAmount: u64
): Promise<DepositDecision> {
const request: ProofRequest = {
requirements: {
proofTypes: ["zkc:kyc:v1", "zkc:source:v1", "zkc:sanctions:v1"],
parameters: {
"zkc:kyc:v1": { minLevel: "enhanced" },
"zkc:source:v1": { acceptableSources: ["employment_income", "regulated_exchange"] },
"zkc:sanctions:v1": { requiredLists: ["OFAC", "EU"] }
},
maxProofAge: Duration.hours(24),
context: this.generateContext()
},
trustPolicy: this.trustPolicy
};
const result = await this.verifier.verify(userProof, request);
if (result.valid) {
return { allowed: true };
} else {
return { allowed: false, reasons: result.errors };
}
}
}
// DeFi protocol integration
class ComplianceDeFiVault {
private verifier: ZKCVerifier;
// On-chain compliance check (via oracle or ZK verification)
async checkAccess(
userAddress: Address,
userProof: ComplianceProof
): Promise<boolean> {
// Verify proof off-chain
const result = await this.verifier.verify(userProof, this.accessPolicy);
if (!result.valid) return false;
// Record compliance attestation on-chain
// (doesn't reveal proof contents, just that verification passed)
await this.recordComplianceAttestation(
userAddress,
hash(userProof),
this.verifier.id
);
return true;
}
}7. Circuit Specifications
7.1 KYC Proof Circuit
KYC PROOF CIRCUIT
═════════════════
Public Inputs:
- holderCommitment: Hash
- claimBinding: Hash
- proofContext: Hash
- accumulatorRoot: Hash
- accumulatorEpoch: u64
- timestamp: Timestamp
- currentEpoch: u64
- issuerPublicKeyX: Bytes32
- issuerPublicKeyY: Bytes32
Private Inputs:
- credential: KYCCredential
- credentialBindingKey: Scalar
- accumulatorWitness: AccumulatorWitness
- requiredLevel: u8
- requiredAttributes: [bool; 5]
Constraints:
1. BINDING VERIFICATION
holderCommitment == H("zkc/binding", credentialBindingKey)
credential.body.holderCommitment == holderCommitment
2. SIGNATURE VERIFICATION
verify_signature(credential, credential.signature)
3. NON-REVOCATION
verify_accumulator_membership(
credential.body.credentialId,
accumulatorWitness,
accumulatorEpoch
)
4. LEVEL CHECK
credential.body.claims.level >= requiredLevel
5. ATTRIBUTE CHECK
for i in 0..5:
if requiredAttributes[i]:
credential.body.claims.verifiedAttributes[i] == true
6. EXPIRY CHECK
credential.header.expiresAt > timestamp OR credential.header.expiresAt == null
7. CLAIM BINDING
claimBinding == H("zkc/claim", requiredLevel, requiredAttributes, proofContext, timestamp)
Parameters:
- Proving system: Noir / UltraHonk (BN254)
- Rows: ~2^17
- Proving time: ~3s (native), ~5s (WASM)
- Proof size: ~6 KB
- Verification time: ~15ms
The zkc_kyc Noir package exposes the public inputs in the order shown above. The zkc:proof:v1 envelope maps them into publicInputs.{holder_commitment, claim_binding, proof_context, accumulator_root, accumulator_epoch, timestamp, current_epoch, issuer_public_key_x, issuer_public_key_y}. The proof body remains the UltraHonk proof artifact; the envelope is an SDK or verifier serialization of the proof plus these public inputs.
7.2 Jurisdiction Proof Circuit
JURISDICTION PROOF CIRCUIT
══════════════════════════
Public Inputs:
- holderCommitment: Hash
- claimBinding: Hash
- proofContext: Hash
- jurisdictionSetRoot: Hash
- membershipType: u8 (0 = inclusion, 1 = exclusion)
- accumulatorRoot: Hash
- accumulatorEpoch: u64
- timestamp: Timestamp
- currentEpoch: u64
- issuerPublicKeyX: Bytes32
- issuerPublicKeyY: Bytes32
Private Inputs:
- credential: JurisdictionCredential
- credentialBindingKey: Scalar
- accumulatorWitness: AccumulatorWitness
- jurisdictionMerklePath: MerklePath
Constraints:
1. BINDING VERIFICATION (same as KYC)
2. SIGNATURE VERIFICATION (same as KYC)
3. NON-REVOCATION (same as KYC)
4. JURISDICTION SET MEMBERSHIP
if membershipType == 0: // inclusion
verify_merkle_membership(
credential.body.claims.jurisdiction,
jurisdictionMerklePath,
jurisdictionSetRoot
)
else: // exclusion
verify_merkle_non_membership(
credential.body.claims.jurisdiction,
jurisdictionMerklePath,
jurisdictionSetRoot
)
5. VALIDITY PERIOD
credential.body.claims.validFrom <= timestamp
credential.body.claims.validUntil >= timestamp
6. CLAIM BINDING
claimBinding == H("zkc/claim", jurisdictionSetRoot, membershipType, proofContext, timestamp)
Parameters:
- Rows: ~2^17
- Proving time: ~3s
- Proof size: ~6 KB
The initial zkc_jurisdiction Noir profile implements inclusion as Merkle membership in jurisdictionSetRoot and exclusion as a sorted-neighbor non-membership proof against a blocked-set root, which is sound only over a canonically constructed set (§7.2c). The zkc:proof:v1 envelope maps the public inputs into publicInputs.{holder_commitment, claim_binding, proof_context, jurisdiction_set_root, membership_type, accumulator_root, accumulator_epoch, timestamp, current_epoch, issuer_public_key_x, issuer_public_key_y}.
7.2a Remaining Predicate Profiles
The zkc_remaining_predicates and _v2 Noir packages define the immutable
initial circuit profiles for the remaining ZKC-Standard/ZKC-Full predicates.
The additive _v3 linkable and _v4 anonymous/scoped packages bind the exact
flagRequired request bit without changing the issuer credential digest. The
published _v5 linkable and _v6 anonymous/scoped packages retain their
existing identities. Additive _v7 linkable and _v8 anonymous/scoped
packages retain that four-slot transcript and public ABI while combining the
raw-code, exact-adjacency, and validity-bound credential protections:
- Accreditation: prove credential level is at least the requested level and, when requested, prove the institutional flag.
- Source-of-funds: prove the credential source code is a member of a verifier accepted-source Merkle root.
- Tax-residency: prove tax jurisdiction membership and a minimum tax year.
- Sanctions-clearance: prove sorted-neighbor non-membership against a blocked screening-list Merkle root, which is sound only over a canonically constructed set (§7.2c).
All eight profiles share the same base constraints: holder binding, issuer signature verification, non-revocation, credential validity period, proof context binding, and claim binding. In v3-v8, the private flag_required value participates in the four-slot public claim binding and, on accreditation, gates the check of issuer-signed claim_1. V7/v8 authenticate validFrom in the issuer credential digest, require source/tax raw codes to be non-zero, and require sanctions neighbour indices to be consecutive. Existing v1 credentials remain usable only for historical verification; current proof generation requires the issuer-authenticated v2 credential schema. Each proof remains atomic; aggregation MUST preserve separate predicate consent.
The v1-v6 circuit identities are immutable and remain available only for historical verification under an explicit policy that accepts deprecated entries. A holder MUST resolve new linkable remaining-predicate proofs to v7 and new anonymous/scoped proofs to v8; it MUST NOT fall back to v1-v6 when a current entry is unavailable.
7.2b Issuer-Authoritative Selective Disclosure
The zkc_selective_disclosure_v1 circuit proves holder binding, anonymous or scoped presentation binding, an issuer signature over the complete fixed-slot attribute credential, fresh non-revocation, exact positional disclosure, and a typed transcript bound to proofContext and timestamp.
Public Inputs:
presentation_pseudonym, scope_id, claim_binding, proof_context,
accumulator_root, accumulator_epoch, timestamp, current_epoch,
issuer_public_key_x[32], issuer_public_key_y[32],
credential_attribute_count, disclosure_count,
disclosed_flags[8], disclosed_attributes[8]
The circuit MUST reject issuer-signature mismatch, noncanonical credential padding, disclosure outside the active count, public values that differ from their issuer-signed positions, nonzero hidden values, count/mask mismatch, expired or revoked credentials, stale accumulator state, and transcript or presentation mismatch. A conforming verifier MUST require principal consent, enforce the versioned type-specific proof-entry schema without additional fields, and require the proof's accumulator epoch/root to exactly match an authenticated issuer root accepted under §2.4.1. In particular, metadata v1, an unsigned convenience field, a self-asserted metadata key, a malformed or stale v3 announcement, or a metadata/announcement mismatch is not an authenticated root; missing or downgraded root data is a fail-closed rejection. See docs/zkc_selective_disclosure.md.
7.2c Canonical Blocked-Set Construction and Root Distribution
This section is normative for jurisdiction exclusion and
sanctions-clearance blocked sets. It also defines the reference canonical
construction available to jurisdiction inclusion. The public API is exported
from zkc_crypto; its deterministic conformance vectors and implementation are
part of the repository's PUBLIC_ALLOWLIST.
Source-of-funds and tax-residency are not covered by this u16 protocol. Their
private codes are full Field values. In deprecated remaining-predicate
profiles v1-v4, raw code 0 aliases an empty padding leaf. Current v7/v8
profiles close that ambiguity by constraining the raw code to be non-zero
without narrowing the full field domain; for example, code 70000 remains
valid. They retain the direct request-root binding of §6.2. Applying this
builder to them would truncate their domain and is non-conforming.
7.2c.1 Why construction and distribution are protocol requirements
The exclusion branches prove non-membership by exhibiting two blocked-set leaves that contain a subject between them. A Merkle root commits to leaf values and positions, but does not attest that the values were unique, sorted, consecutive, or bounded by sentinels. A verifier that accepts an opaque root cannot determine whether its tree has the structure required by the proof.
The verifier therefore MUST publish both the canonical code list and its
derived root in the signed Authenticated Proof Request (APR). The holder and
verifier MUST independently rebuild the same tree. A root literal by itself is
not evidence of canonical construction, and an exclusion request containing
only a root MUST be rejected. The verifier MUST compare the rebuilt root
directly with the proof public input jurisdiction_set_root or set_root;
recomputing only claim_binding is insufficient because the remaining-
predicate claim-binding preimage does not contain set_root.
The reference implementation is python/zkc_crypto/canonical_set.py.
tests/vectors/canonical_set_vectors.json and
tests/vectors/validate_canonical_set_vectors.py pin the construction,
positive witnesses, boundary behavior, and malformed-input refusals.
7.2c.2 Modes and canonical procedure
The construction mode is mandatory:
- Exclusion is used by jurisdiction when
membershipType = 1and always by sanctions clearance. It is sentinel-bracketed. - Inclusion is used by jurisdiction when
membershipType = 0. It is not sentinel-bracketed: otherwise the circuit's ordinary membership branch would make the sentinel codes provable members.
Given an array of codes and a mode, a conforming implementation MUST:
- Reject a non-array input, a non-integer or boolean entry, a reserved or out-of-domain code, and an offline construction input longer than 65,534 entries before deduplication.
- Deduplicate repeated codes; duplicates are not otherwise an error.
- Sort the member codes in ascending numeric order.
- In exclusion mode only, prepend code
0and append code65535. - Place the resulting leaves at consecutive indices beginning with index 0.
- Encode every occupied leaf as specified by §7.2c.4.
- Pad the rest of the depth-32 tree using §7.2c.3.
The construction is deterministic. Input ordering and repetition do not alter the root. Inclusion and exclusion over the same member codes intentionally produce different roots because only exclusion has sentinel leaves.
Empty arrays are valid. Empty exclusion produces the two sentinel leaves,
which bracket every valid subject. Empty inclusion occupies no leaves, has root
zeroSubtree[32], and cannot yield a membership witness.
7.2c.3 Code domain, sentinels, depth, and padding
Jurisdiction and sanctions-clearance codes are u16. The canonical domain is:
| Role | Code |
|---|---|
| Minimum exclusion sentinel | 0 |
| Valid member and subject codes | 1 .. 65534 |
| Maximum exclusion sentinel | 65535 |
Codes 0 and 65535 are reserved in both modes and MUST NOT be supplied as
members or subjects. A deployment needing either value MUST remap its code
space. Sentinels are encoded like other occupied leaves but are structural
brackets, not policy members.
Trees are depth-32 Poseidon2 Merkle trees. A path index is consumed least-
significant-bit first, with bit i selecting left or right at tree level i.
Padding uses the recursive zero-subtree convention:
zeroSubtree[0] = 0
zeroSubtree[k + 1] = H(zeroSubtree[k], zeroSubtree[k])
An absent subtree of height k contributes zeroSubtree[k], not a literal
zero except at height 0. Consequently, a one-member canonical tree is not the
same as a one-leaf tree whose 32 siblings are all literal zero.
For raw sanctions leaves, the minimum sentinel encodes to field element 0,
the same value as an empty leaf. Implementations MUST use the occupied index,
not the leaf value alone, to identify the sentinel. Its fixed position at index
0 and the maximum sentinel after the last member preserve the bracket
invariant.
7.2c.4 Predicate leaf encodings
| Predicate | Mode | Leaf encoding |
|---|---|---|
| Jurisdiction | Inclusion or exclusion | H(code, requiredClaimType) |
| Sanctions clearance | Exclusion | code as a field element |
requiredClaimType MUST be an integer that fits in u8 and is folded into
every occupied jurisdiction leaf, including exclusion sentinels. A jurisdiction
root is therefore scoped to one claim type. Sanctions roots have no leaf-level
domain separator and MUST NOT be reused for another predicate.
7.2c.5 Authenticated request parameter contract
For jurisdiction exclusion, the signed APR parameters for the proof type MUST contain all of:
membershipType: 1
requiredClaimType: <u8 integer>
jurisdictionCanonicalSet: <array of canonical-domain codes>
jurisdictionSetRoot: <BN254 field integer>
For sanctions clearance, the signed APR parameters MUST contain:
canonicalSet: <array of canonical-domain codes>
setRoot: <BN254 field integer>
The root fields distribute the verifier-selected root to the holder. The code arrays make that root independently derivable. Both are required for exclusion and MUST agree. The holder MUST verify the APR signature and effective-request agreement, derive the root, compare it with the APR root, and build the membership or adjacent-neighbour witness locally before proving. APR-supplied path indices or sibling arrays are not authoritative and MUST NOT override the locally derived witness.
An online APR code array MUST contain no more than 4,096 entries before
deduplication. This request-processing budget is intentionally tighter than the
65,534-code offline construction limit: a valid signature authenticates the
sender but does not make its computational workload safe. Holders and verifiers
MUST reject an oversized signed array before copying or hashing it. The
reference limit is exported as CANONICAL_SET_MAX_REQUEST_CODES.
The verifier MUST perform the same derivation from its authenticated request, compare the result with the APR root, then compare that result directly with the proof public input. A mismatch at any comparison is a rejection. A bare root fallback, warning-only provenance, or use of an operator-supplied root without derivation is non-conforming for exclusion.
Jurisdiction inclusion MAY retain the established root-only request shape,
because it does not use the sorted-neighbour argument. If
jurisdictionCanonicalSet is present, jurisdictionSetRoot MUST also be
present and equal the canonical inclusion root, and the holder/verifier MUST
perform the same independent derivation and comparison. The inclusion code
array contains no sentinels.
canonicalSet is reserved for sanctions and jurisdictionCanonicalSet for
jurisdiction. Supplying either reserved key to source-of-funds, tax-residency,
accreditation, or another unsupported predicate, or supplying the wrong key to
a supported predicate, MUST be rejected rather than silently ignored.
7.2c.6 Witness behavior and boundary cases
For inclusion, the holder MUST refuse to produce a membership witness for an absent code. For exclusion, it MUST refuse to produce a non-membership witness for a present code. A subject below the smallest member is bracketed by the minimum sentinel and that member; a subject above the largest is bracketed by the largest member and the maximum sentinel. The empty exclusion set uses the two sentinels directly.
The reference builder returns consecutive predecessor and successor indices. The verifier's canonical derivation guarantees that an honest holder receives the same root and can produce its witness without APR-distributed paths.
7.2c.7 Implementation status of the circuit adjacency constraint
This subsection records a current implementation limitation; it does not weaken the construction or request obligations above.
The historical remaining-predicate v1-v4 and jurisdiction v1/v2 circuits
verify membership of both neighbours and the inequality
predecessor < subject < successor, but do not constrain
successor_path_index == predecessor_path_index + 1. Canonical construction
and derived-root binding prevent substitution of a different tree, but cannot
make those circuits inspect adjacency. A malicious prover can use two
non-adjacent leaves from the honest canonical tree to bracket a code that is
actually present.
The remaining-predicate v5-v8 sanctions branches and jurisdiction v3/v4 exclusion branches additionally constrain the successor index to equal the predecessor index plus one. They are sound only when combined with every canonical construction and authenticated-root obligation above. Implementations MUST NOT describe historical remaining-predicate v1-v4 or jurisdiction v1/v2 exclusion proofs as sound against a malicious prover.
7.2c.8 Implementer note (non-normative)
The external construction obligation is a consequence of proving non-membership with sorted neighbours over a plain Merkle tree. An indexed accumulator whose leaf commits to its successor could let the circuit attest adjacency directly, at the cost of a different leaf format and update procedure. Such a circuit is outside this version.
7.3 Aggregation Circuit
PROOF AGGREGATION CIRCUIT
═════════════════════════
Public Inputs:
- inputProofCommitments: Hash[]
- aggregatedClaimBinding: Hash
- holderCommitment: Hash
Private Inputs:
- inputProofs: ZKProof[]
- inputPublicInputs: PublicInputs[]
Constraints:
1. VERIFY EACH INPUT PROOF
for i in 0..n:
verify_proof(inputProofs[i], inputPublicInputs[i])
2. SAME HOLDER
for i in 0..n:
inputPublicInputs[i].holderCommitment == holderCommitment
3. COMMITMENT CONSISTENCY
for i in 0..n:
inputProofCommitments[i] == H(inputProofs[i])
4. CLAIM BINDING
aggregatedClaimBinding == H(inputProofCommitments)
Parameters:
- Rows: ~2^15 + n * 2^14 (grows with input count)
- Max inputs: 8 (configurable)
- Recursive verification supported
The initial zkc_aggregation Noir package supports up to 8 atomic predicate proof slots. Until backend-native recursive verification is stable, it follows the ZKA simulated-recursion profile by committing to verification keys, proof bytes, public inputs, and proof type per slot. Aggregation requires all active proofs to share the same holderCommitment, requires an explicit consent flag per active proof, and derives aggregatedClaimBinding from the aggregation commitment and proof context. The JSON envelope schema is schemas/compliance-proof-v1.schema.json.
7.4 Authenticated Credential Rebind Circuits
This section specifies the additive, issuer-authenticated successors to the deprecated credential-rebind statements of §4.5. Design rationale is recorded in ADR 0004 (docs/adr/0004-authenticated-credential-rebind.md). Both successors prove, for one credential, that (a) the credential was created by a specific issuer for a specific holder commitment, schema, identifier, and validity window, (b) the credential is a live member of that issuer's non-revocation accumulator at a fresh epoch, (c) the prover controls the binding key behind that holder commitment, and (d) the prover controls the binding key behind a distinct new holder handle. Nothing about the credential is prover-chosen: every credential field in the statement is either public and compared by the verifier, or bound by the issuer's signature recomputed in-circuit.
7.4.1 The zkc:attestation:credential-rebind:v1 transcript
An issuer that permits a credential to be rebound signs a rebind attestation for it, at issuance time or later on the holder's request. The attestation is schema-agnostic: it binds the credential's identity rather than its schema-specific claims, so one circuit serves every credential schema.
REBIND_ATTESTATION_TYPE = string_to_field("zkc:attestation:credential-rebind:v1")
issuerIdHash = string_to_field(issuerId)
schemaIdHash = string_to_field(schemaId)
header = Poseidon4(DOMAIN_ZKC_CLAIM, REBIND_ATTESTATION_TYPE, issuerIdHash, schemaIdHash)
body = Poseidon4(holderCommitment, credentialId, validFrom, validUntil)
digest = Poseidon2(header, body)
signature = ECDSA_secp256k1_sign(issuerSigningKey, digest.to_be_bytes(32))
DOMAIN_ZKC_CLAIMis the registeredzkc/claimdomain (§1.3); the explicit type field and fixed slot order make the transcript distinct from every credential digest of §3 and from the selective-disclosure transcript of §7.2b, so no signature over one can be replayed as another.issuerIdandschemaIdare the exact strings the issuer publishes in its metadata (§4.1);holderCommitmentandcredentialIdare the credential's own;validFrom/validUntilareu64seconds withvalidUntil == 0meaning no expiry and otherwisevalidUntil > validFrom.- The signing key is an issuer signing key published in issuer metadata, with the same
ECDSA_SECP256K1profile as credential signatures (§2.3). Verifiers trust it by exact key, never by registry membership (Safeguard 1). - Issuers MUST NOT sign a rebind attestation for a credential they did not issue, and MUST NOT include any new-holder value in it: the attestation names only the holder the credential is currently bound to, so issuing it reveals nothing about a future rebind and does not create an old-to-new mapping (§4.5 audit-mapping rules are unaffected).
The credential envelope (§4.3) carries the attestation as an additive rebindAttestation member of credential:
"rebindAttestation": {
"version": "zkc:attestation:credential-rebind:v1",
"schemaId": "zkc:cred:kyc:v1",
"validFrom": 1000,
"validUntil": 2000000,
"signature": "0x…64 bytes…"
}
issuerId, holderCommitment, and credentialId are taken from the enclosing credential, so the attestation cannot name a different credential than the one it travels with.
7.4.2 zkc:proof:credential_rebind:v3 — linkable profile (zkc_credential_rebind_auth)
AUTHENTICATED CREDENTIAL REBIND CIRCUIT (linkable)
═══════════════════════════════════════════════════
Public Inputs (declaration order; 75 field elements):
0 old_holder_commitment: Field
1 new_holder_commitment: Field
2 credential_id: Field
3 issuer_id_hash: Field
4 schema_id_hash: Field
5 proof_context: Field
6 timestamp: u64
7 claim_binding: Field
8 accumulator_root: Field
9 accumulator_epoch: u64
10 current_epoch: u64
11 issuer_public_key_x: [u8; 32]
12 issuer_public_key_y: [u8; 32]
Private Inputs:
old_credential_binding_key: Field
new_credential_binding_key: Field
credential_valid_from: u64
credential_valid_until: u64
issuer_signature: [u8; 64]
nonrevocation_siblings: [Field; 32]
nonrevocation_path_index: Field
statement_domain: Field
Constraints:
1. STATEMENT IDENTITY
statement_domain == string_to_field("zkc:proof:credential_rebind:v3")
2. HOLDER CONTROL (old and new)
old_holder_commitment == H("zkc/binding", old_credential_binding_key)
new_holder_commitment == H("zkc/binding", new_credential_binding_key)
old_holder_commitment != new_holder_commitment
3. ISSUER AUTHENTICATION (recomputed transcript, no digest input)
digest = rebind_attestation_digest(issuer_id_hash, schema_id_hash,
old_holder_commitment, credential_id,
credential_valid_from, credential_valid_until)
verify_signature_secp256k1(issuer_public_key_x, issuer_public_key_y,
issuer_signature, digest.to_be_bytes())
4. NON-REVOCATION (same credential_id as the transcript)
verify_accumulator_membership(credential_id, nonrevocation_path_index,
nonrevocation_siblings, accumulator_root)
current_epoch >= accumulator_epoch
current_epoch - accumulator_epoch <= MAX_EPOCH_LAG
5. VALIDITY
credential_valid_from <= timestamp
credential_valid_until == 0 OR credential_valid_until >= timestamp
timestamp != 0
6. CONTEXT AND CLAIM BINDING
proof_context != 0
claim_binding == compute_claim_binding_3(old_holder_commitment, new_holder_commitment,
credential_id, proof_context, timestamp)
The proof is carried in a zkc:proof:v1 envelope whose holderBinding.commitment MUST equal old_holder_commitment (the rebind is presented under the holder's current identity).
7.4.3 zkc:proof:rebind:v4 — scoped profile (zkc_credential_rebind_auth_v2)
AUTHENTICATED CREDENTIAL REBIND CIRCUIT (scoped)
═════════════════════════════════════════════════
Public Inputs (declaration order; 76 field elements):
0 old_presentation_pseudonym: Field
1 new_presentation_pseudonym: Field
2 scope_id: Field
3 credential_id: Field
4 issuer_id_hash: Field
5 schema_id_hash: Field
6 proof_context: Field
7 timestamp: u64
8 claim_binding: Field
9 accumulator_root: Field
10 accumulator_epoch: u64
11 current_epoch: u64
12 issuer_public_key_x: [u8; 32]
13 issuer_public_key_y: [u8; 32]
Private Inputs:
old_holder_commitment: Field
new_holder_commitment: Field
old_credential_binding_key: Field
new_credential_binding_key: Field
credential_valid_from: u64
credential_valid_until: u64
issuer_signature: [u8; 64]
nonrevocation_siblings: [Field; 32]
nonrevocation_path_index: Field
statement_domain: Field
Constraints:
1. STATEMENT IDENTITY
statement_domain == string_to_field("zkc:proof:rebind:v4")
2. SCOPED PRESENTATION (no anonymous rebind)
scope_id != 0
old_presentation_pseudonym == H("zkc/presentation-scope:v1", old_credential_binding_key, scope_id)
new_presentation_pseudonym == H("zkc/presentation-scope:v1", new_credential_binding_key, scope_id)
old_presentation_pseudonym != new_presentation_pseudonym
3. HOLDER CONTROL over the (private) commitments — as 7.4.2 constraint 2
4. ISSUER AUTHENTICATION — as 7.4.2 constraint 3, over the private old_holder_commitment
5. NON-REVOCATION — as 7.4.2 constraint 4
6. VALIDITY — as 7.4.2 constraint 5
7. CONTEXT AND CLAIM BINDING
proof_context != 0
claim_binding == compute_claim_binding_3(old_presentation_pseudonym, new_presentation_pseudonym,
credential_id, proof_context, timestamp)
The proof is carried in a scoped zkc:proof:v2 envelope whose presentationBinding.pseudonym MUST equal old_presentation_pseudonym and whose presentationBinding.scopeId MUST equal scope_id (§5.9.4 rules 2–4).
7.4.4 Holder rules
- A holder MUST build the witness only from a stored credential that carries a
rebindAttestation, and MUST verify the attestation signature locally against the trust policy's key for the credential's issuer before proving. Missing, zero, malformed (wrong length or non-canonical), mismatched (different issuer, schema, holder, or identifier), expired (validity window excludes the request timestamp), stale (accumulator witness older than the lag bound and not refreshable), or revoked material MUST fail closed before any proof is generated; the reference SDK raisesConstraintViolationError/StaleWitnessErrorand never copies unauthenticated credential-object fields into the witness. - The new binding key is holder-generated (
newCredentialBindingKeyornewMasterSeedrequest parameter) and never leaves the holder. - Presentation follows §5.9: the linkable profile yields
zkc:proof:credential_rebind:v3; the scoped profile yieldszkc:proof:rebind:v4; the anonymous profile has no rebind statement and MUST be refused. - No downgrade. A request MUST name a successor identity explicitly. The deprecated identities of §4.5 MUST NOT be resolved, negotiated, or emitted, and a successor request MUST NOT be silently satisfied by a deprecated statement or vice versa.
7.4.5 Verifier rules
The successor rows of the §6.2 type-specific table are normative. In particular a verifier MUST require, for the proof's public issuer key, an exact trust-policy match that is trusted for the proof type; MUST require issuer_id_hash == string_to_field(issuerId) of the matched issuer; MUST hold an authenticated accumulator state (§2.4, metadata v2 signed announcement chain) for that issuer and reject a proof whose current_epoch differs from its own view, whose accumulator_epoch is ahead of that view or older than the tighter of the policy and announcement lag bounds, or whose accumulator_root differs from the recorded root for that epoch; and MUST apply the optional schemaIdHash request pin only after these checks. The per-field contract is machine-readable in schemas/public-input-bindings/zkc_credential_rebind_auth{,_v2}.json, where issuer_id_hash is bound (compared against the verifier's own matched issuer identifier) rather than an optional filter. A prover-controlled current_epoch or accumulator_root is never revocation authentication, and an Authenticated Proof Request never substitutes for issuer authentication. verify() and verify_authenticated() MUST apply identical semantics.
7.4.6 Migration, recovery, privacy, and vectors
- Existing credentials. An issuer MAY emit the rebind attestation for an already-issued credential on request (representation-only: no credential field, digest, or witness changes). Credentials without an attestation cannot be rebound through this mechanism and MUST NOT be rebound through the deprecated one.
- Issuer-assisted recovery. The attestation does not enable recovery of a credential whose old binding key is lost: constraint 2 requires the old key. Recovery is re-issuance (§4.3), and any issuer-retained mapping remains subject to §4.5.
- Context binding and expiry.
proof_contextandtimestampare inside the claim binding; the credential validity window is issuer-signed; envelope freshness is verified per §6.2 steps 1–2. - Privacy. Relative to the deprecated statements the disclosed set is unchanged: the linkable profile reveals both holder commitments, the scoped profile both pseudonyms; both reveal
credential_id,issuer_id_hash,schema_id_hash, the issuer key, and the accumulator root/epochs, exactly as every issuer-authenticated predicate reveals its issuer key and accumulator state.credential_idremains a stable cross-scope handle in the scoped profile (inherited fromzkc:proof:rebind:v2, documented here, not widened). The §5.9.5 determinism invariant holds:claim_bindingretainsproof_contextandtimestamp. - Vectors.
tests/vectors/zkc_core_vectors.jsoncredential_rebind_attestation_v1pins the transcript type field, issuer/schema hashes, holder commitments, digest, the TEST-ONLY issuer signature, both statement domains, the scoped pseudonyms, and both claim bindings; conformance MUST additionally cover an accepted live credential and rejection of fabricated, zero-signature, tampered-field, expired, revoked, stale/future-epoch, untrusted-key, wrong-scope, relabelled, and deprecated-identity cases for both profiles (§9.2).
8. Security Considerations
8.1 Cryptographic Assumptions
| Assumption | Consequence if Broken |
|---|---|
| Discrete log (BN254) | Credential binding key recovery |
| Poseidon collision resistance | Credential forgery |
| ZK proving system soundness (UltraHonk/Barretenberg) | Invalid proofs accepted if broken |
| BLS signature security | Credential signature forgery |
| Accumulator security | Revocation bypass |
8.2 Privacy Properties
| Property | Guarantee |
|---|---|
| Credential binding pseudonymity | holderCommitment = H("zkc/binding", cbk) hides the holder's seed and real-world identity from the verifier, but it is a stable, deterministic, cross-verifier-linkable pseudonymous anchor, not an unlinkability mechanism. The v2 anonymous profile removes the linkable anchor from default presentations entirely (see §5.9); a scoped presentation exposes only a scope-exclusive presentationPseudonym. |
| Seed-path unlinkability | The ZKA value-address and ZKC credential-commitment derive along separate KDC A.6 paths and are unlinkable without the seed. This is not cross-verifier multi-show unlinkability. |
| Issuer privacy | Verifier learns issuer type, not specific issuer (configurable) |
| Claim privacy | Only requested claims are proven, others hidden |
| Temporal privacy | Credential issuance date hidden |
Known limitation / open item. This equality-linkability limitation applies to v1/linkable envelopes (zkc:presentation:linkable:v1): two presentations of the same credential that expose the same holderCommitment are linkable by equality, including by colluding verifiers. Which presentation profile governs a given presentation now determines the exposure (§5.9): the default anonymous profile exposes no holder-derived handle (pseudonym and scope are 0), a scoped presentation exposes only a scope-exclusive presentationPseudonym, and the stable holderCommitment is exposed only under the explicitly consented linkable profile. Genuine cross-verifier multi-show unlinkability is thus a matter of profile choice; the accepted design direction and its ladder are recorded in docs/adr/0001-multi-show-unlinkable-presentations.md.
8.3 Attack Mitigations
| Attack | Launch mitigation and residual risk |
|---|---|
| Credential sharing | Binding key tied to spending key. |
| Proof replay | Context binding and timestamps. |
| Issuer collusion | Verifier-controlled trust policies. |
| Accumulator stalling | Epoch freshness requirements reject an announcement whose signed announcedAt or epoch lag is outside local policy. This is a partial launch mitigation: a verifier cannot prove that an issuer has omitted a newer state that the verifier has never observed. |
| Announcement omission | Historical backfill and fail-closed gap handling expose an omitted intermediate epoch to a verifier that later receives a successor. This is a partial launch mitigation: a party kept on an internally consistent prefix cannot distinguish omission from an idle issuer without an independent observation. |
| Announcement replay | The signed timestamp, monotonic epoch, and persisted local checkpoint reject an older announcement after a newer one has been accepted. Cold start still depends on freshness policy and cannot establish that no fresher view exists elsewhere. |
| Revocation split view / issuer equivocation | A pinned signing key prevents third-party substitution, and the complete predecessor-announcement commitment makes a conflicting successor detectable once compared with a verifier's persisted checkpoint. This is a partial launch mitigation, not global consistency: a malicious issuer can sign and serve two individually valid histories to isolated parties. The conflicting signed announcements are evidence of equivocation only when the views meet through direct comparison, monitoring, or gossip. |
| Timing correlation | Randomized proof generation. |
These guarantees are deliberately distinct. Signature verification establishes third-party substitution resistance; predecessor and checkpoint comparison provide locally observed fork detection; neither supplies global split-view detection. A verifier MUST NOT infer that its valid local chain is the issuer's only chain.
Post-launch work specifies a per-issuer CT-v2-style append-only revocation log. Log inclusion and consistency proofs can establish membership and consistency within the view a log serves, but consistency proofs alone do not reveal two isolated, internally consistent views. Global split-view detection therefore also requires independent monitors and cross-party checkpoint gossip.
The rows above are normative launch requirements. The reference issuer now publishes signed v3 metadata, and its durable service variant persists the ordered announcement history. The verifier and private witness provider now enforce pre-pinned key and accumulator ids, exact metadata equality, signed freshness, bounded ordered backfill, durable checkpoints, replay/linkage rejection, and signed conflict evidence retention. End-to-end launch conformance additionally requires passing the authenticated-revocation conformance suite.
8.4 Metadata Leakage (Non-Normative Pointer)
The tables above address cryptographic attacks. Metadata that leaks AROUND the cryptography — predicate type, issuer choice, proof timing, verifier endpoints, credential-freshness queries, registry lookups — is analyzed systematically (per flow × observer, with implemented mitigations and honest residual risk) in the reference threat model: docs/zkc_metadata_leakage_threat_model.md. Implemented reference mitigations include local-copy registry reads, credential-anonymous witness refresh (zkc:epoch-delta-request:v1), and constant-shape refusals; the reference test suite pins these behaviors (tests/python/test_metadata_leakage.py). This section is a pointer only — it changes no envelope, circuit, or verification rule.
9. Conformance
9.1 Conformance Levels
Core conformance is a strictly monotonic ladder; each level is a superset of the one below it. ZKC-Full is the highest core level.
| Level | Requirements |
|---|---|
| ZKC-Core | KYC proofs, basic verification |
| ZKC-Standard | Core + jurisdiction, accreditation, source |
| ZKC-Full | Standard + tax, sanctions, aggregation |
ZKC-Agent is an orthogonal extension profile — not a level above ZKC-Full. It denotes a deployment that exposes the agent API (§5.4) for automated compliance on top of any claimed core level (typically ZKC-Full). Tooling MUST NOT infer an ordering in which ZKC-Agent ranks above ZKC-Full, and MUST treat the level name as ZKC-namespaced rather than inferring rank from any other protocol's conformance taxonomy.
9.2 Test Vectors
Canonical ZKC core test vectors are published in tests/vectors/zkc_core_vectors.json. Implementations MUST treat these as golden vectors for the BN254/Poseidon2 profile: domain separators, Poseidon2 KATs, KDC A.6 credential binding derivation, holder commitment derivation, claim binding examples, and the purpose-bound context KATs (purposeTag derivation and the purpose-bound context preimage, §2.2) MUST match byte-for-byte.
The same vector file contains the selective_disclosure_v1 issuer-digest and typed-transcript KATs plus the required negative-case inventory, and the credential_rebind_attestation_v1 KATs for the issuer-authenticated rebind transcript, statement domains, scoped pseudonyms, and claim bindings of §7.4. The transcript uses the AFP-KDC v1.0.3 current registry's zkc/claim operational domain with an explicit zkc:proof:selective_disclosure:v1 type field; the domain field and Poseidon2 profile remain immutable AFP-KDC v1.0.2 crypto evidence.
Authenticated revocation launch conformance is pinned by tests/vectors/revocation_equivocation_v1.json and the network-independent conformance/run_revocation.py runner. The public deterministic vectors cover the exact signed transcript and complete predecessor hash, every signed-field tamper, metadata mismatch, unauthorized key substitution and rotation, replay, gap/backfill failure, durable restart, and signed fork evidence. They also pin the genesis-to-issuance-to-revocation adapter lifecycle. The cold-start case accepts a correctly pinned first announcement only as a local checkpoint; it rejects an untrusted or malformed first view and makes no global consistency claim. The two-view case demonstrates that isolated, internally consistent forks remain undetected until their signed checkpoints are compared.
A runnable conformance suite for the behavioral requirements (holder generation/refusal, verifier acceptance/rejection with structured error codes, and the executable Freedom Safeguard checks for Safeguards 1, 4, and 6) is published in conformance/ with machine-readable ZKC-Core vectors and a documented third-party adapter contract (conformance/README.md). Step-4e request binding is executable, not prose-only: KYC, jurisdiction, accreditation, source-of-funds, tax-residency, and sanctions-clearance each have an honest round-trip control plus a core-verifier-*-requirements-not-met case that leaves a valid proof untouched and verifies it against a different, internally valid request. A conforming verifier MUST reject each such out-of-request proof with REQUIREMENTS_NOT_MET; INVALID_PROOF does not satisfy these cases because no proof byte or public input was mutated. The Safeguard 6 (verifier accountability) coverage includes generation refusal on an unsigned request, PURPOSE_MISMATCH when a billing-purpose proof is presented to an access-purpose request, third-party Disclosure-Receipt round-trip verification with no operator in the loop, and hosted-SDK receipt-retention refusal (safeguard-6-* cases). The concrete canonical serialization of the purpose-bound, APR, and Disclosure-Receipt digests — in particular how the structured proofTypes and parameters members commit into the field-based preimage, and how the receipt binds the signed APR — is pinned byte-for-byte by the reference implementation (python/zkc_crypto/apr.py) and these vectors, which are authoritative for clean-room reproduction. Profiles above Core attach to the same runner as expansion hooks.
Appendix A: Well-Known Identifier Registry (Non-Exclusive)
This appendix is a non-exclusive registry of well-known identifiers published for interoperability. It is not a closed or mandated schema, and it does not define an "official" or "default" credential or predicate set (see §1.4, Safeguard 1).
Normatively:
- No credential type, proof type, or provider listed here is privileged or required by the protocol.
- A verifier's trust policy (§6) MAY accept credential types, proof types, predicates, or issuers that are not listed here, and is never obligated to accept any that are.
- The identifier registry defines no mechanism for designating, registering, or blessing a canonical credential or predicate catalog; listing an identifier here confers no special status.
- The credential schemas in §3.1–3.7 are reference schemas for the corresponding well-known types — illustrative interoperability profiles, not the only permissible structures for a given concept.
Well-known credential and proof type identifiers (reference, non-exhaustive):
const CREDENTIAL_TYPES = {
KYC_V1: "zkc:cred:kyc:v1",
JURISDICTION_V1: "zkc:cred:jurisdiction:v1",
ACCREDITATION_V1: "zkc:cred:accreditation:v1",
SOURCE_V1: "zkc:cred:source:v1",
TAX_V1: "zkc:cred:tax:v1",
SANCTIONS_V1: "zkc:cred:sanctions:v1",
};
const PROOF_TYPES = {
KYC_V1: "zkc:proof:kyc:v1",
JURISDICTION_V1: "zkc:proof:jurisdiction:v1",
ACCREDITATION_V1: "zkc:proof:accreditation:v1",
SOURCE_V1: "zkc:proof:source:v1",
TAX_V1: "zkc:proof:tax:v1",
SANCTIONS_V1: "zkc:proof:sanctions:v1",
// Historical, contained identifier (§4.5): authenticates no credential;
// holders never generate it and verifiers reject it unconditionally.
CREDENTIAL_REBIND_V1: "zkc:proof:credential_rebind:v1",
// Issuer-authenticated credential rebind (§7.4): linkable and scoped.
CREDENTIAL_REBIND_V3: "zkc:proof:credential_rebind:v3",
REBIND_V4: "zkc:proof:rebind:v4",
};
const ATTESTATION_TYPES = {
// Issuer-signed credential transcript consumed by the §7.4 rebind circuits.
CREDENTIAL_REBIND_V1: "zkc:attestation:credential-rebind:v1",
};Appendix B: Issuer Reputation (Future)
Framework for decentralized issuer reputation (draft):
// Reputation accumulates from:
// - Successful verifications (verifiers report)
// - Age of operation
// - Stake (if applicable)
// - Regulatory status
interface IssuerReputation {
issuerId: IssuerIdentifier;
metrics: {
totalCredentialsIssued: u64;
totalVerifications: u64;
successfulVerifications: u64;
// Decentralized reputation score
reputationScore: u64; // 0-1000
// Time-weighted factors
operatingSince: Timestamp;
lastActivity: Timestamp;
};
// Verifier-submitted reports
reports: {
verifier: VerifierIdentifier;
outcome: "success" | "failure" | "suspicious";
timestamp: Timestamp;
}[];
}Document History
| Version | Date | Changes |
|---|---|---|
| Unreleased | 2026-09-06 | Security containment of unauthenticated credential-rebind statements. §4.5 records that zkc:proof:credential_rebind:v1, zkc:proof:rebind:v2, zkc:proof:credential_rebind:v2, and zkc:proof:rebind:v3 prove holder-key continuity only (their credential fields are unconstrained witnesses; no issuer signature or accumulator membership is verified) and are never evidence of credential ownership or live continuity. Holders MUST NOT generate them; verifiers MUST reject them at every entry point with the new structured error UNAUTHENTICATED_CREDENTIAL (§6.2), independent of trust policy, request pins, or request authentication. Historical circuits, encoders, sidecars, artifacts, and registry pins are byte-frozen; the four registry entries are deprecated with no historical-verification exception. Authenticated credential rebind (§7.4, ADR 0004). Specifies the issuer-signed zkc:attestation:credential-rebind:v1 transcript (Poseidon2(Poseidon4(DOMAIN_ZKC_CLAIM, type, issuerIdHash, schemaIdHash), Poseidon4(holderCommitment, credentialId, validFrom, validUntil))), the additive linkable zkc:proof:credential_rebind:v3 and scoped zkc:proof:rebind:v4 circuits that recompute it from constrained fields, verify the issuer signature against the public key, and prove fresh non-revocation for the same credential identifier, their exact public/private input ABIs and claim bindings, the verifier's issuer-trust, issuer-identity, and authenticated-revocation acceptance rules (§6.2), the no-downgrade rule, the privacy analysis, migration of existing credentials, and the credential_rebind_attestation_v1 known-answer vectors (§9.2). |
| Unreleased | 2026-09-03 | Added the issuer-authoritative zkc:cred:attribute-set:v1 / zkc:proof:selective_disclosure:v1 reference profile, fixed-position Noir circuit, typed AFP-KDC-domain transcript, versioned proof-entry and public-input schemas, KATs, and ZKA/AFP opaque-consumer boundary. Canonicalized master seeds as exact 32-byte values under the frozen afp-kdc:master-seed:v1.0.1 big-endian-mod-r profile; added boundary/asymmetric vectors, strict host decoding, full-entropy generation, and representation-only migration rules without changing existing identities. Updated current authority to AFP-KDC v1.0.2, added seven raw-sponge KATs and the explicit 260-field parameter digest gate in Noir and Python, and synchronized the ZKM-aware protocol-family epoch without changing any derived value or frozen identity. Defined the step-4e verifyTypeSpecificRequirements request-binding contract, including default-deny UNBOUND_PROOF_TYPE behavior, and added honest plus out-of-request conformance vectors for every Core compliance predicate family. Clarified the delegation-token trust boundary: holder authorization MUST anchor token keys to authenticated local holder state, same-seed minting MUST bind the delegation key and holder commitment, and token-carried key coordinates remain compatibility metadata rather than a trust anchor. This clarification changes no token wire format, circuit, proof envelope, or delegated authority. |
| 0.1.0 | 2026-01-19 | Initial specification |
| 0.2.0 | 2026-02-16 | Proving system migration: Halo 2 (Pallas) → Noir/UltraHonk (BN254). Halo2Proof → ZKProof throughout. Protocol now proving-system-agnostic at spec level. |
| 0.2.1 | 2026-03-06 | Notation alignment with ZKA v0.5.0: H(x) updated to Poseidon2 over BN254 with domain tag; commit(v, r) updated from Pedersen to Poseidon2; §8.1 cryptographic assumptions removes stale Halo 2 reference. |
| 0.2.2 | 2026-05-15 | Alignment with ZKA v0.6.0 Freedom Safeguards (ZKA §1.6). Added §1.4 Freedom Safeguards (normative), including the open-source predicate-verification mandate (Safeguard 4) and the §2.1 ZKA §5.2 cross-reference. Reframed Appendix A as a non-exclusive well-known identifier registry and §3.1–3.6 as reference (non-mandatory) schemas per Safeguard 1 (Predicate Pluralism). Made §9.1 conformance monotonic: ZKC-Full is the highest core level; ZKC-Agent is an orthogonal extension profile, not a level above Full. Resolves the three ZKC-layer divergences in ZKA v0.6.0 Appendix C.1. |
| 0.2.3 | 2026-06-04 | Credential-binding-key derivation relocated to the Key Derivation Core (KDC v1.0.0, AFP Specification Appendix A) and referenced normatively from §2.1; §1.3 notation re-pointed from a ZKA-version reference to KDC §A.3. The CredentialBinding interface is unchanged. Relocation only — no change to any derived value, credential structure, circuit, or test vector. |
| 0.2.5 | 2026-06-11 | Registry governance: added §4.2a (reference registry layout, advisory/non-exclusive/immutable-version constraints, local-copy operation, membership-is-not-trust conformance hook) anchoring the in-repo registry/ and docs/zkc_predicate_registry_governance.md. Normative deltas restate §1.4 Safeguards 1/4 for registries; no change to credentials, circuits, proofs, or test vectors. Same-day addendum: non-normative §8.4 metadata-leakage pointer to docs/zkc_metadata_leakage_threat_model.md (no normative change). |
| 0.2.4 | 2026-06-10 | Align proof context binding with ZKA v0.6.2-draft transaction-absent work-attestation bundles. ProofContext is now profile-defined and hash-based, preserving verifier-session contexts while allowing AFP session_context to bind ZKC proofs carried in pure Coord/work-attestation bundles. Adds non-custodial delegated proof generation, service-boundary requirements, and sealed/minimized issuer audit mappings for credential rebinding. ZKA remains authoritative for zka:bundle:v1 substantiveness and coord_proof verification. |
| 0.3.0 | 2026-06-13 | Verifier accountability. Added Safeguard 6 — Verifier Accountability (§1.4, mirrored normatively from ZKA §1.6), the first safeguard binding the counterparty rather than the operator. Added the zkc:context:purpose-bound:v1 profile binding declared purpose into the proof context via purposeTag (§2.2); the Authenticated Proof Request (§5.5); the holder-custodied Disclosure Receipt (§5.6); the plaintext-disclosure scope rule (§5.7); and the normative residual limits — decision-accountability ceiling, verifier-identity continuity, verifier-deniability cost (§5.8). Added the verifier-side authenticated-request pre-step with UNSIGNED_REQUEST / PURPOSE_MISMATCH structured errors (§6.2) and the safeguard-6-* executable conformance vectors plus purpose-bound golden KATs (§9.2). No change to existing circuits, credentials, notes, bundles, or proof envelopes; all prior proofs remain valid instances and existing STALE_PROOF / CONTEXT_MISMATCH semantics are unchanged. |
| 0.3.1-draft | 2026-06-15 | Co-publication cross-reference harmonization: the §2.2 work-attestation-bundle reference is retargeted from ZKA v0.6.2 to ZKA v0.7 (the co-published ZKA revision). No change to credentials, circuits, proofs, contexts, conformance, or test vectors — ZKC v0.3.0's verifier-accountability machinery (Safeguard 6, Authenticated Proof Request §5.5, Disclosure Receipt §5.6, purpose-bound context §2.2) already supports unknown-counterparty verification with no modification. See the AFP ADR for the cross-spec unknown-counterparty trust-model record. |
| 0.3.2-draft | 2026-07-02 | Corrects the §8.2 privacy-properties claim for holderCommitment: current presentations are pseudonymous but linkable by equality when the stable commitment is public. Adds the multi-show unlinkability ADR pointer and records the scoped-pseudonym direction for a future presentation-profile change. |
| 0.4.0-draft | 2026-07-02 | Presentation-profile ladder (implements amended ADR 0001). Adds §5.9 Presentation Profiles (normative): the three profiles (zkc:presentation:anonymous:v1 default, :scoped:v1, :linkable:v1), the pseudonym/scope/verifier-identity derivations, the APR presentation block, per-profile Disclosure Receipts, the verifier rules with new structured errors PROFILE_MISMATCH / SCOPE_MISMATCH / PROFILE_NOT_ACCEPTED, and the public-input determinism invariant as a MUST. Documents the zkc:proof:v2 envelope (presentationBinding, no holderBinding) in §6.1 and updates §8.2 (anonymous profile removes the linkable anchor from default presentations; the equality-linkability limitation applies to v1/linkable envelopes, governed by profile choice). v1 circuits/encoders/envelopes/registry entries stay byte-for-byte frozen; all changes are additive. |
| 0.4.1-draft | 2026-08-05 | Documents the protocol-family boundary between ZKC eligibility, ZKM authorization, and ZKA settlement; assigns zkc/* and zkm/* domain-namespace ownership; identifies ProofContextProfile as the extension point for a future ZKM-defined profile; and clarifies that ZKC delegated proving and delegateViewing convey neither mandate nor spending authority. Documentation only: no change to credentials, circuits, schemas, proof envelopes, presentation profiles, conformance, or test vectors. |
License
This specification is released under the Apache 2.0 License.
Patent Non-Assertion Covenant
The Apache 2.0 patent grant (License §3) covers patent claims necessarily infringed by a contribution alone or by its combination with the Work it was submitted to. An independent implementation of this specification — one written from the specification text without deriving from any reference code — falls outside that scope, and so is not clearly covered by that grant. The following covenant, modeled on the Open Web Foundation Agreement (OWFa) 1.0 non-assert and the patent commitment of the Community Specification License 1.0, closes that gap. By submitting a contribution to this specification (for example, a pull request modifying this document), each contributor accepts and makes this covenant for every version of this specification that incorporates its contribution.
Each contributor to this specification irrevocably covenants, on behalf of itself and its successors and assigns, not to assert any Essential Claims against any party for making, having made, using, selling, offering for sale, importing, or distributing a Conformant Implementation of this specification. Essential Claims are patent claims owned or controlled by the contributor — now or in the future, including claims later acquired — that are necessarily infringed by implementing the required portions (those designated MUST, REQUIRED, or SHALL) of a version of this specification to which the contributor contributed. "Necessarily infringed" follows the W3C Patent Policy definition of Essential Claims: it applies only where no commercially reasonable non-infringing alternative exists, and it excludes claims covering implementation choices this specification leaves open and technologies this specification merely references rather than describes in detail. A Conformant Implementation is any implementation that conforms to those required portions — whether or not it derives from any contributor's code or specification text; where an implementation forms part of a larger work, this covenant extends only to the portions that so conform. This covenant is royalty-free and worldwide, and supplements — never limits — the rights granted under the Apache 2.0 License.
This covenant runs with the Essential Claims: it is intended to bind any future owner, assignee, or exclusive licensee that acquires the right to enforce them, and a contributor that transfers a patent containing Essential Claims satisfies this obligation by notifying the transferee of this covenant. The covenant is suspended with respect to any party that asserts a patent infringement claim alleging that a Conformant Implementation of this specification infringes that party's patents (excluding claims brought defensively in response to a prior such assertion by a contributor or any successor or assignee bound by this covenant), for as long as that assertion is maintained.
ZKC: User-controlled compliance for privacy-preserving systems.