Zero-Knowledge Proof Toolbox
npm install @prifilabs/zk-toolboxThis library explores practical design patterns using zero-knowledge proofs, with a focus on ZK-SNARKs. Rather than diving deep into the mathematical foundations, it adopts a hands-on approach to demonstrate how zero-knowledge techniques can be applied to real-world software scenarios.
It's intended for anyone interested in learning how to use ZK-SNARKs through practical examples. Start by reviewing the examples below to understand each use case, then dive into the source code, especially the ZK circuits written in CIRCOM, to explore how the proofs are constructed.
For a more details about those proofs, check our HandsOnZkProofs website.
I'm Thierry Sans, an Associate Professor at the University of Toronto and co-founder of PriFi Labs. I built this library as a teaching tool to illustrate concrete use cases of zero-knowledge proofs for my students and the broader developer community.
- Install
- Security Disclaimer
- Known Bugs
- Using The Library
- Proof Of Commitment
- Proof Of Membership
- Proof Of Exclusion
- Binding Proof of Membership with Proof of Exclusion
- Proof Of Encryption
- Proof Of Signature
- Proof Of Location
- Contribute
- Credits
- AI-Generated Content Notice
- Contact Us
- License
``bash`
npm install zk-toolbox
This package includes a post-install script that automatically downloads the zk-SNARK witness files needed to create proofs. Those files were generated using the Perpetual Power of Tau (ppot_0080). If you want to recompile them, use the Makefile from the Github repository (see the Contribute section below).
⚠️ This library involves cryptography and zero-knowledge proofs. It has not been audited and may contain undiscovered vulnerabilities. Do not use it in production, in security-critical systems, or to protect sensitive data without a comprehensive third-party audit. Use at your own risk. We welcome community review and feedback.
When generating a proof, the program does not terminate automatically. This behavior is most likely caused by snarkjs leaving behind a lingering WebAssembly worker.
As a workaround, the examples in this library explicitly call process.exit(0) to ensure the process exits cleanly after proof generation.
This section walks through the various zero-knowledge proof design patterns demonstrated in this library. Each example illustrates a different use case and shows how ZK-SNARKs can be applied in practice.
In Proof of Commitment, the prover demonstrates knowledge of a secret preimage corresponding to a public hash value, without revealing the secret itself. This proof uses the secret as a private input and a nonce as a public input, producing two public outputs:
- secretHash: the hash of the secretauthHash
- : a hash binding of the secret and the nonce to prevent replay attacks and/or to tie the proof to a specific action or time frame (i.e context binding)
Step 1: Generate Inputs
`js
import { ProofOfCommitment, randomBigInt32ModP, poseidon } from "@prifilabs/zk-toolbox";
const privateInputs = { secret: randomBigInt32ModP() };
const publicInputs = { nonce: randomBigInt32ModP() };
`
Step 2: Generate the Proof
`js`
const proofOfCommitment = new ProofOfCommitment();
const { proof, publicOutputs } = await proofOfCommitment.generate(privateInputs, publicInputs);
Step 3: (Optional) Verify Output Correctness
This step checks that the public outputs match expected values based on the inputs. It’s not required for verification but useful for debugging and understanding.
`js`
const secretHash = poseidon([ privateInputs.secret ]);
console.assert(publicOutputs.secretHash == secretHash);
const authHash = poseidon([ privateInputs.secret, publicInputs.nonce ]);
console.assert(publicOutputs.authHash == authHash);
Step 4: Verify the Proof
Only the proof, public inputs, and public outputs are required for verification, never the private inputs.
`js`
const res = await proofOfCommitment.verify(proof, publicInputs, publicOutputs);
console.assert(res);
process.exit(0);
In Proof of Membership, the prover demonstrates that they know a secret which is part of a larger set, without revealing which specific element it is.
In addition, this proof includes a nullifier scheme designed to prevent double-use of the same secret in multiple proofs. A nullifier is a cryptographic commitment derived from the secret that can be published to signal that the secret has been used, without revealing the secret itself. This mechanism is common in anonymous systems like mixers or anonymous voting, where a prover should only be able to generate a valid proof once per secret, without revealing their identity.
To enable this, we define a commitment as the hash of the tuple (secret, nullifier). This binds the secret to its nullifier in a way that maintains privacy but enforces uniqueness: the same secret cannot be reused without producing the same commitment.
This proof leverages Merkle proofs, a cryptographic technique for efficiently verifying membership of a commitment in a set represented as a Merkle tree, without revealing which commitment it is. In essence, Proof of Membership is a zero-knowledge proof of a Merkle inclusion proof.
The proof inputs and outputs are:
- Private input:
- secret: the value whose membership is being provencommitments
- Public inputs:
- : a list of commitments in the set that will be the leaves of the Merkle treenullifier
- : the public value that wil be tied to secret as the commitmentnonce
- : a contextual binding value
- Public output:
- authHash: a hash of the secret, the nullifier and nonce, preventing replay attacks and enabling context binding
`js
import { ProofOfMembership, randomBigInt32ModP, poseidon } from "@prifilabs/zk-toolbox";
let secret, nullifier, commitment;
const commitments = [];
const random = Math.floor(Math.random() * 100);
for (let i =0; i<100; i++){
const s = randomBigInt32ModP();
const n = randomBigInt32ModP();
const c = poseidon([s, n]);
if (random == i){
secret = s;
nullifier = n;
commitment = c;
}
commitments.push(c);
}
const nonce = randomBigInt32ModP();
const privateInputs = { secret };
const publicInputs = { commitments, nullifier, nonce };
const proofOfMembership = new ProofOfMembership();
const { proof, publicOutputs } = await proofOfMembership.generate(privateInputs, publicInputs);
console.assert(publicOutputs.authHash == poseidon([privateInputs.secret, publicInputs.nullifier, publicInputs.nonce]));
const res = await proofOfMembership.verify(proof, publicInputs, publicOutputs);
console.assert(res);
process.exit(0);
`
Proof of Exclusion is the complement of Proof of Membership: it allows a prover to demonstrate that a specific commitment they know is not included in a public set of commitments, without revealing the actual value.
This type of proof is especially useful in privacy-preserving systems, such as cryptocurrency mixers, where users may want to prove they are not associated with certain entries. For instance, mixers are often used to anonymize transactions, but some deposits may originate from illicit sources. A Proof of Exclusion enables users to prove that their withdrawal is not linked to any "tinted" or flagged deposits—without exposing which deposit they actually own.
In systems that combine both Proof of Membership and Proof of Exclusion, users can:
- Prove they are authorized to act (e.g., by proving inclusion in a deposit set)
- Simultaneously demonstrate that their deposit is not part of a restricted or flagged set
The proof inputs and outputs are:
- Private input:
- secret: the value whose membership is being proven
- Public inputs:
- commitments: the list of known "tinted" or flagged commitmentsnullifier
- : the public value that wil be tied to secret as the commitmentnonce
- : a contextual binding value
- Public output:
- authHash: a hash of the secret, the nullifier and nonce, preventing replay attacks and enabling context binding
`js
import { ProofOfExclusion, randomBigInt32ModP, poseidon } from "@prifilabs/zk-toolbox";
const commitments = [];
for (let i =0; i<100; i++){
commitments.push(poseidon([randomBigInt32ModP()]));
}
const secret = randomBigInt32ModP();
const nullifier = randomBigInt32ModP();
const nonce = randomBigInt32ModP();
const privateInputs = { secret };
const publicInputs = { commitments, nullifier, nonce };
const proofOfExclusion = new ProofOfExclusion();
const { proof, publicOutputs } = await proofOfExclusion.generate(privateInputs, publicInputs);
console.assert(publicOutputs.authHash == poseidon([privateInputs.secret, publicInputs.nullifier, publicInputs.nonce]));
const res = await proofOfExclusion.verify(proof, publicInputs, publicOutputs);
console.assert(res);
process.exit(0);
`
A proof of exclusion can be linked to a proof of membership to enables powerful privacy-preserving statements such as "my commitment is part of a certain collection (inclusion) but it is not among the blacklisted one (exclusion)".
But how to prove to a verifier that a proof of membership and a proof of exclusion take the same commitment value since this value is private (hence not known by the verifier)?
An elegant solution is to bind these two proofs together through the authHash output. Both Proof of Membership and Proof of Exclusion have the same authHash as output:
``
authHash = Poseidon(secret, nonce)
Therefore, to link two proofs together, the prover should use the same secret, the same nullifier and the same nonce in both proofs. The verifier must check that each proof outputs the same authHash:
``
authHash_membership == authHash_exclusion
Proof of Encryption allows the prover to demonstrate that they know the plaintext message and encryption key corresponding to a given ciphertext, without revealing either of them. This is a zero-knowledge proof of correct symmetric encryption.
This construction uses the Chacha20 stream cipher. The proof shows that the ciphertext was correctly derived by encrypting the private plaintext with the private key using ChaCha20, and that the prover knows both values.
This kind of proof can be useful in systems that need to demonstrate possession of an encrypted message or credential without revealing its contents. For example:
- A user may prove they hold the correct decryption key for an encrypted token
- A service could verify that a submitted ciphertext corresponds to a known encryption process, without ever seeing the underlying data
The proof inputs and outputs are:
- Private input:
- plaintext: the original 128-byte messageencryptionKey
- : the 32-byte encryption key
- Public inputs:
- encryptionNonce: the 12-byte encryption noncezkNonce
- : a contextual binding value
- Public output:
- ciphertext: the 128-byte encrypted message (generated via ChaCha20)authHash
- : a hash of the encryptionKey and zkNonce, preventing replay attacks and enabling context binding
`js
import { ProofOfEncryption, pad, randomBigInt32ModP, poseidon, uint8ArrayToBigInt } from "@prifilabs/zk-toolbox";
// npm install @noble/ciphers
import { chacha20 } from '@noble/ciphers/chacha';
import { utf8ToBytes } from '@noble/ciphers/utils';
import { randomBytes } from '@noble/ciphers/webcrypto';
const privateInputs = {
plaintext: pad(utf8ToBytes('The quick brown fox jumps over the lazy dog!')),
encryptionKey: randomBytes(32),
};
const publicInputs = {
encryptionNonce: randomBytes(12),
zkNonce: randomBigInt32ModP(),
};
const proofOfEncryption = new ProofOfEncryption();
const { proof, publicOutputs } = await proofOfEncryption.generate(privateInputs, publicInputs);
const ciphertext = chacha20(privateInputs.encryptionKey, publicInputs.encryptionNonce, privateInputs.plaintext);
console.assert(Buffer.from(publicOutputs.ciphertext).toString('hex') === Buffer.from(ciphertext).toString('hex'));
const authHash = poseidon([
uint8ArrayToBigInt(privateInputs.encryptionKey.slice(0, privateInputs.encryptionKey.length/2)),
uint8ArrayToBigInt(privateInputs.encryptionKey.slice(privateInputs.encryptionKey.length/2)),
publicInputs.zkNonce
]);
console.assert(publicOutputs.authHash === authHash);
const res = await proofOfEncryption.verify(proof, publicInputs, publicOutputs);
console.assert(res);
process.exit(0);
`
> [!NOTE]
> The zk-SNARK witness file for Proof of Signature is 800 MB and was not included in the library. If you want to use this feature, you will need to compile it using the Makefile from the Github repository (see the Contribute section below).
Proof of Signature is implemented here primarily as a proof of concept rather than a practical solution. At its core, a digital signature is already a form of zero-knowledge proof: it demonstrates that the signer possesses the private key corresponding to a given public key, without revealing the key itself.
However, integrating signature verification within a zero-knowledge proof can offer practical advantages. Since ZK-SNARKs can be verified efficiently, especially in blockchain environments, it can be useful to bundle signature checks into a broader ZK circuit, rather than verifying them separately off-chain or in on-chain logic.
In this example, we focus on the ECDSA signature scheme using the _secp256k1_ curve and _SHA3_ as the hashing function. This is the most widely used scheme in cryptocurrencies like Bitcoin and Ethereum. However, ECDSA is not ZK-friendly: its algebraic structure is poorly suited for efficient proof generation in ZK-SNARK systems. As a result, generating this proof is computationally expensive.
For reference, generating a single proof on a MacBook M2 Pro takes approximately 2 minutes and 30 seconds.
The proof inputs and outputs are:
- Public inputs:
- msgHash: the message hash computed using SHA3signature
- : the ECDSA signature over msgHash
- Public output:
- pubkey: the public key recovered from the msgHash and signature
`js
import { ProofOfSignature, getECDSAInputs, randomBigInt32ModP, poseidon } from "@prifilabs/zk-toolbox";
// npm install @noble/secp256k1 @noble/hashes
import * as secp from '@noble/secp256k1';
import { keccak_256 } from '@noble/hashes/sha3.js';
import { utf8ToBytes } from '@noble/hashes/utils';
const privKey = secp.utils.randomPrivateKey();
const message = "Hello World!";
const msgHash = Buffer.from(keccak_256(utf8ToBytes(message))).toString('hex');
const signature = await secp.signAsync(msgHash, privKey);
const privateInputs = {};
const publicInputs = { msgHash, signature };
const proofOfSignature = new ProofOfSignature();
const { proof, publicOutputs } = await proofOfSignature.generate(privateInputs, publicInputs);
const pubkey = publicInputs.signature.recoverPublicKey(Buffer.from(publicInputs.msgHash, 'hex')).toHex();
console.assert(publicOutputs.pubkey === pubkey);
const { s } = getECDSAInputs(publicInputs.msgHash, publicInputs.signature);
const res = await proofOfSignature.verify(proof, publicInputs, publicOutputs);
console.assert(res);
process.exit(0);
`
Proof of Location allows the prover to demonstrate that their current geographic coordinates fall within a specified area, defined by a central point and radius, without revealing their exact location.
This type of zero-knowledge proof is useful in applications where location-based access or eligibility needs to be enforced without compromising user privacy. Examples include geo-fenced voting, location-based access to digital assets, or location-sensitive identity claims.
To preserve privacy while ensuring soundness, the exact coordinates are kept private. A private nonce value is mixed into the location hash to protect against brute-force attacks on the hashed output. This prevents adversaries from reverse-engineering the location by guessing plausible coordinates.
The proof inputs and outputs are:
- Private input:
- location: the prover’s exact latitude and longitude coordinatesnonce
- : a random value used to salt the location hashcenter
- Public inputs:
- : the latitude and longitude of the center of the allowed arearadius
- : the radius (in meters) defining the boundary of the area
- Public output:
- locationHash: a hash of the location and secret, ensuring the coordinates remain hidden
`js
import { ProofOfLocation, randomBigInt32ModP, poseidon } from "@prifilabs/zk-toolbox";
// npm install random-location
import { randomCirclePoint, distance} from 'random-location';
const TORONTO = {
latitude: 43.653908,
longitude: -79.384293,
};
const RADIUS = 50000;
const location = randomCirclePoint(TORONTO, RADIUS);
const nonce = randomBigInt32ModP();
const privateInputs = { location, nonce };
const publicInputs = { center: TORONTO, radius: RADIUS };
const proofOfLocation = new ProofOfLocation();
const { proof, publicOutputs } = await proofOfLocation.generate(privateInputs, publicInputs);
const res = await proofOfLocation.verify(proof, publicInputs, publicOutputs);
console.assert(res);
process.exit(0);
`
All code is on Github.
Install SnarkJs Globally
`bash`
npm install -g snarkjs@latest
To compile the CIRCOM files:
`bash`
make clean // removes zk-data and contracts
make ptau // download the ptau file (if needed) from the Perpertual Power of Tau repo
make proofs // compiles all CIRCOM files
To run the tests:
`bash``
npm install
npx test
Building On the Shoulders of Giants:
* snarkjs
* cicomlib
* @semaphore-protocol/semaphor
* poseidon-lite
* @zk-kit/incremental-merkle-tree
* @reclaimprotocol/circom-chacha20
* @personaelabs/efficient-zk-ecdsa
* @noble/ciphers
* @noble/hashes
* @noble/secp256k1
Portions of this README were written with the assistance of AI to improve clarity and style. However, no part of the source code was generated by AI, all code was written and reviewed by a human developer.
Join us on PriFi Labs' Discord