npm package: @prifilabs/zk-toolbox
Description: Zero-Knowledge Proof Toolbox
Author: Thierry Sans
Latest version: 1.0.1

# Zero-Knowledge Proof Toolbox

This 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](https://thierrysans.me/HandsOnZkProofs/) website. 

I'm [Thierry Sans](https://thierrysans.me/), 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.

## Table of Contents

- [Install](#install)
- [Security Disclaimer](#security-disclaimer)
- [Known Bugs](#known-bugs)
- [Using The Library](#using-the-library)
  - [Proof Of Commitment](#proof-of-commitment)
  - [Proof Of Membership](#proof-of-membership)
  - [Proof Of Exclusion](#proof-of-exclusion)
  - [Binding Proof of Membership with Proof of Exclusion](#binding-proof-of-membership-with-proof-of-exclusion)
  - [Proof Of Encryption](#proof-of-encryption)
  - [Proof Of Signature](#proof-of-signature)
  - [Proof Of Location](#proof-of-location)
- [Contribute](#contribute)
- [Credits](#credits)
- [AI-Generated Content Notice](#ai-generated-content-notice)
- [Contact Us](#contact-us)
- [License](#license)

## Install

```bash
npm install @prifilabs/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)](https://github.com/privacy-ethereum/perpetualpowersoftau). If you want to recompile them, use the `Makefile` from the Github repository (see the [Contribute](#contribute) section below).

## Security Disclaimer

⚠️ 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.

## Known Bug

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.

## Using the Library

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.

### Proof Of Commitment

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 secret
- `authHash`: 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);
```

### Proof Of Membership

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 proven
  
- **Public inputs**:  
  - `commitments`: a list of commitments in the set that will be the leaves of the Merkle tree
  - `nullifier`: the public value that wil be tied to secret as the commitment
  - `nonce`: 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

 **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 commitments
  - `nullifier`: the public value that wil be tied to secret as the commitment
  - `nonce`: 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);
```

### Binding Proof of Membership with Proof Of Exclusion

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

**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*](https://en.wikipedia.org/wiki/Salsa20#ChaCha_variant) 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 message
   - `encryptionKey`: the 32-byte encryption key

 - **Public inputs**:
   - `encryptionNonce`: the 12-byte encryption nonce
   - `zkNonce`: 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);
```

### Proof Of Signature

> [!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](#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 SHA3
   - `signature`: 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

**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` coordinates
  - `nonce`: a random value used to salt the location hash
  
- **Public inputs**:  
  - `center`: the `latitude` and `longitude` of the center of the allowed area
  - `radius`: 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);
```

## Contribute

All code is on [Github](https://github.com/prifilabs/zk-toolbox).

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
```

## Credits

Building On the Shoulders of Giants:

* [snarkjs](https://www.npmjs.com/package/snarkjs)
* [cicomlib](https://www.npmjs.com/package/circomlib)
* [@semaphore-protocol/semaphor](https://github.com/semaphore-protocol/semaphore)
* [poseidon-lite](https://www.npmjs.com/package/poseidon-lite)
* [@zk-kit/incremental-merkle-tree](https://www.npmjs.com/package/@zk-kit/incremental-merkle-tree)
* [@reclaimprotocol/circom-chacha20](https://www.npmjs.com/package/@reclaimprotocol/circom-chacha20)
* [@personaelabs/efficient-zk-ecdsa](https://github.com/personaelabs/efficient-zk-ecdsa)
* [@noble/ciphers](https://www.npmjs.com/package/@noble/ciphers)
* [@noble/hashes](https://www.npmjs.com/package/@noble/hashes)
* [@noble/secp256k1](https://www.npmjs.com/package/@noble/secp256k1)

## AI-Generated Content Notice

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.

## Contact Us

Join us on [PriFi Labs' Discord](https://discord.gg/tGVYjcqG)

## License

[MIT License](https://opensource.org/license/mit/)