Proof Configuration
So you know you can use GPCs to make proofs about PODs, but how do you specify what you want to prove? This page covers the full configurability of GPCs, and all the things you can prove with them.
Configuring a Proof
When you generate a proof, you say what to prove using two arguments. Proof Configuration specifies what you’re proving as a set of constraints. It also gives names to the inputs and specifies which parts of those inputs should be revealed. Proof Inputs provide data which satisfies the constraints, using the names assigned by the config.
A proof config is often fixed by the app use case, and reused for many proofs. If not hard-coded, the config is often part of a request from the verifier which triggers the creation of a proof. In contrast, at least some of the inputs are likely to vary with each proof. It might be up to the end user to select the inputs which satisfy the constraints.
Each of the constraints of a proof correspond to a module in the ZK circuit used to generate the proof. For instance, each POD mentioned in the configuration needs an Object module to verify its signature. Each entry mentioned in the configuration needs an Entry module to verify its inclusion in the POD’s Merkle tree.
You don’t need to interact with modules directly because the GPC compiler translates the application’s configuration and inputs into the numeric inputs required by circuit modules. It also picks a circuit from the available family with enough modules to accept those inputs. More modules means a larger circuit and a more expensive proving process (more time, more memory).
The page below walks through all of the supported constraints you can use in your applications. But first let’s walk through an example configuration.
A Real-World Example
A forum app wants a user to prove they attended a community event without revealing which one. Zupass holds a user’s tickets to various events as PODs. When the user logs in, the forum sends a request using the Z API, specifying the fields of the ticket POD to check or reveal (constraints). The forum also provides a list of acceptable event IDs or ticket signing authorities (to include in the inputs). Zupass then allows the user to pick from their tickets which match these constraints, and generate a proof to send back to the forum.
You can learn more about how make this request with the Z API on the Ticket Proofs page, but for this discussion we’re interested in the resulting GPCProofConfig which could be defined in TypeScript like this:
{ const ticketConfig = { pods: { ticket: { entries: { attendeeEmail: { isRevealed: true }, eventId: { isRevealed: false, isMemberOf: "validEventIDs" }, owner: { isRevealed: false, isOwnerID: "SemaphoreV4" } },
signerPublicKey: { isRevealed: false, isMemberOf: "trustedTicketSigners" } } } }}
The meaning of this configuration should be easy to grasp, but we’ll get into all the details below. This config implies the need four inputs, which are given names in the configuration. Some of these names are cryptographically important while others are developer choice:
ticket
identifies a POD containing at least 3 entries namedattendeeEmail
,eventId
, andowner
.- The name
ticket
is arbitrary, used to link together config and inputs. - the names of the entries are cryptographically verified, and were selected by the POD’s signer.
- The name
validEventIDs
andtrustedTicketSigners
name lists of values which are compared to values in the POD.- The list names are arbitrary, used to link together config and inputs.
SemaphoreV4
identifies the type of private key the owner must provide to prove their ownership of the POD.- The name
SemaphoreV4
corresponds to one of the identity formats supported by the library.
- The name
What if the community forum was open only to people who attended more than one event? A multi-POD configuration could prove that too:
{ const twoTicketConfig = { pods: { ticket1: { entries: { attendeeEmail: { isRevealed: true }, eventId: { isRevealed: false, isMemberOf: "validEventIDs" }, owner: { isRevealed: false, isOwnerID: "SemaphoreV4" } },
signerPublicKey: { isRevealed: false, isMemberOf: "trustedTicketSigners" } } ticket2: { entries: { attendeeEmail: { isRevealed: false, equalsEntry: "ticket1.attendeeEmail" }, eventId: { isRevealed: false, isMemberOf: "validEventIDs", notEqualsEntry: "ticket1.eventId" }, owner: { isRevealed: false, isOwnerID: "SemaphoreV4" } },
signerPublicKey: { isRevealed: false, isMemberOf: "trustedTicketSigners" } } } }}
We’re now proving the existence of two ticket PODs. Each has similar constraints, but we require they contain the same email address, but two different event IDs (both from the valid list).
In the sections below we’ll discuss the details of how these configurations are structured, and the individual constraints.
Configuration Structure
The GPCProofConfig type is structured around the different categories of constraints you can use in a proof. See the API Reference for full details. The pseudo-code below shows how each category of constraints fits into the configuration. Each comment corresponds to one of the constraint sections below.
const ticketConfig = { pods: { <POD name 1>: { entries: { <entry name 1>: { // Entry constraints for entry 1... }, <entry name 2>... }
// Object constraints for POD 1... } <POD name 2>... }
// Global constraints... }
Supported Constraints
This section covers all possible GPC constraints in these categories:
- Entry constraints apply to a single entry of a single POD. The entry configuration always specifies whether the entry value should be revealed, and usually contains additional constraints on the entry.
- Object constraints apply to a single POD included in the proof. In addition to the named entries of the POD, these include a set of virtual entries which constrain object-level metadata (such as the contentID) in a format similar to entry constraints.
- Global constraints apply to all of the input PODs together. This includes tuples which group together multiple entries as a single value. There are also some global constraints which control the behavior of the overall proof.
- Other Inputs which don’t directly match the config structure are also described below.
Entry Constraints
The named entries of PODs are the primary source of data to constrain in your proofs. Most of them optional.
The following constraints can be applied to any type of value. They are implemented by using the hash of the value.
- Visibility (isRevealed) is the only required configuration for every entry. If set to
true
then the entry value will be included in the revealed claims and visible to the verifier. Otherwise the value is hidden, but can still be constrained. - Equality (equalsEntry, notEqualsEntry) constrains that two entries are (or are not) equal to each other. You specify the second entry to compare using a qualified name like
otherPOD.entryName
, and can refer to virtual entries. Each entry can have only one equality check, but since these are defined unidirectionally you can chain them together to constrain several values to be equal. - List Membership (isMemberOf, isNotMemberOf) constraints that this entry is (or is not) present in a list of values included as a named public input. Each entry can have one membership check or non-membership check (not both). The maximum size of the list depends on the circuit parameters.
- Ownership (isOwnerID) allows the prover to assert ownership of a POD. This requires that the owner present their private ID (key) as one of the proof inputs. This constraint specifically requires the entry value to be equal to the public ID (key) derived from the given private ID. The string in the constraint definition is the identity protocol to use, most often
SemaphoreV4
. This is a specialized equality constraint, so cannot be combined withequalsEntry
ornotEqualsEntry
.
The following numerical constraints can be applied only to numeric values which fit in a 64-bit signed integer (such as the int
, boolean
or date
types). They operate on the value itself, not only its hash. They also implicitly constrain the value to be a number, so they can be used if you want to be sure an unrevealed value is actually a number rather than some other type.
- Bounds Check (inRange, notInRange) constrains a numeric value to be in (or not in) a closed interval between two numbers, specified as signed
bigint
values. You can combine bothinRange
andnotInRange
checks on the same entry. The bounds are limited to a signed 64-bit range, which you can specify explicitly with the POD_INT_MAX and POD_INT_MIN constants. All other numeric and arithmetic operations on entry values require a bound check as a prerequisite, in order to limit the size of the inputs. - Inequality (greaterThan, greaterThanEq, lessThan, lessThanEq) compare this entry to another entry and require the specified numeric relationship. Like equality constraints, you can specify the second entry to compare using a qualified name like
otherPOD.entryName
, and can refer to virtual entries. Each entry can have multiple inequality checks, but only one of each type. Since these are defined unidirectionally you can chain them together to constrain several values in more complex ways.
Virtual Entries
Virtual entries allow you to constrain global properties of a POD using the same syntax as named entries. All constraints on virtual entries are optional, so they are often omitted from configuration if the default behavior is appropriate. There are two virtual entries available in each POD:
- signerPublicKey is the public key of the signer of the POD. This is revealed by default, since you shouldn’t trust a POD if you don’t know who signed it. You can choose to hide this, though you should probably only do so if you are constraining it in some other way, such as using list membership or a tuple.
- contentID is the content ID of the POD, i.e. the root of its Merkle tree, which acts as a succinct identifier of its content. This is private by default, since revealing it reveals some information about the contents of the POD, potentially leading to offline guessing attacks. Comparing an entry of one POD to the content ID of another POD is one way for PODs to refer to or “contain” other PODs.
Virtual entries can be constrained using a subset of the entry constraints described above. Specifically you can specify Visibility, Equality, and List Membership constraints, but not Ownership or Numeric constraints.
Global Constraints
Global constraints apply to the entire proof, or all of the PODs in it:
- circuitIdentifier allows you to optionally specify that a proof should be generated using a specific circuit, rather than picking the best fit from the family. After the proof is generated the config becomes a bound config in which this field is mandatory.
- uniquePODs can be used in a multi-POD proof to constrain that each of the named PODs is unique, meaning no two of them have the same content ID. This defaults to false, meaning it is allowed for the prover to use the same POD to satisfy multiple PODs mentioned in the proof config.
When you specify an entry by a qualified name in a proof, you can specify a virtual entry instead using a prepended $
. For instance if ‘pod1’ refers to ‘pod2’ by ID, you could constrain this using an entry constraint like this:
pod1: { otherPOD: { isRevealed: false, equalsEntry: "pod2.$contentID"} }
Tuples
Tuples allow you to combine multiple entry values into a single value (pair, triple, etc) for use in further constraints. Tuples are defined globally and can contain entries from multiple PODs. A tuple can be constrained in much the same way as an entry, though only List Membership constraints are currently supported.
For example, in the example above, rather than separately constraining the signer and event ID, you could form them into a pair so that each signer is only allowed to issue tickets to their own events. You’d then declare a tuple like this:
const pairConfig = { pods: { /*... as above ...*/ }, tuples: { signerEventPair: { entries: ["ticket.$signerPublicKey", "ticket.eventId"], isMemberOf: "validSignerEventPairs" } }};
To input the list of valid pairs, each tuple is represented in TypeScript as an array of PODValues.
Other Inputs
In addition to providing all of the named PODs and lists mentioned in the configuration, proof inputs can control a few optional features of a proof:
- Watermark: This is an extra value included in the proof in a cryptographically secure way. The watermark can be any PODValue such as a string or number, and can be used to include any extra app-specific data in the proof, often to avoid replay attacks. Watermarks often hold a unique nonce or timestamp, or hold a message which uses the proof in place of a signature. See the page on Verifying Securely for more suggestions.
- Owner: Ownership of a POD can be established by proving that you hold the private key which corresponds to an identity mentioned in the POD. The
isOwnerID
constraint marks which entry of the POD is checked. See the Identity and Ownership page for more details.
Security Reminders
Here are a few reminders of some details above which may be relevant to the security of your app. You can design the schemas of the PODs you issue, or the constraints of your proofs to avoid being tripped up by these issues.
- Anything not mentioned in the configuration isn’t proven and thus may not be true. For example, any entry not named in your proof may or may not be present in the POD, depending on the issuer’s behavior. A value you expect to be a number might actually be a string, unless you constrained it in some way.
- PODs are freely copyable. All the PODs mentioned in a proof could be copies of a single POD, unless you use the
uniquePODs
constraint. - The signers of each POD (
signerPublicKey
) should always be checked in some way (see Verifying Securely). Anyone can sign a POD with a random key, but it’s trust in the issuer which makes a POD meaningful. Signers can be checked in several ways:- Reveal the signing key (default behavior) so the verifier can check it outside the proof.
- Hide the signing key, but constrain it to be a member of a public list.
- Hide the signing key, but constrain it to equal an entry in another “certificate POD” signed by a trusted signer.
- Signers can issue many types of PODs, which could use common names like
email
for different purposes. When designing your POD schemas, consider including apod_type
which verifiers can check. - POD value types are only hints, checked by the library but not by the cryptographic proof. Keep in mind the equivalency categories of values which can be identical across types. For instance, even if the expected schema of a POD specifies an
int
value, you can’t be sure it’s not a huge number until you check it with aninRange
constraint. - You can prove the presence of a named entry without revealing it, but you can’t prove the absence of a named entry without revealing the whole POD. If you’re issuing PODs with optional entries, consider setting them to
null
if you think provers need to constrain “unused” entries.
Additional Resources
- The Proving and Verification page has a deep dive into how your configuration turns into a proof.
- For full details on all parts of configuration, see the API reference for GPCProofConfig and GPCProofInputs.
- The ZuKYC app has a well-documented configuration in its proof request which exercises many features.
- The tutorial code includes example configurations for 1 POD and 2 PODs.