A privacy-preserving identity and authentication protocol built on Ethereum using zk-SNARKs (Groth16). Users can prove they are registered members of a group without revealing who they are, using a Poseidon-hashed commitment stored in a Merkle tree.
- Overview
- How It Works
- Architecture
- Project Structure
- Tech Stack
- Prerequisites
- Setup
- Usage
- Key Concepts
- Security Considerations
- Future Improvements
This project demonstrates a complete ZK-based user registration and authentication flow:
- A user generates a secret identity (nullifier + trapdoor).
- Their commitment (a Poseidon hash of the secrets) is inserted into an on-chain Merkle tree.
- To authenticate, the user generates a Groth16 zk-SNARK proof entirely in-browser, proving they know the secrets behind a commitment in the tree — without revealing the commitment or secrets.
- The smart contract verifies the proof on-chain and prevents replay attacks using a nullifier hash.
User (Browser / CLI)
│
│ 1. User enters a secret PIN
│
▼
Derive: nullifier = secret × 123
trapdoor = secret × 456
│
│ (Secrets NEVER leave the client)
▼
commitment = Poseidon(nullifier, trapdoor)
│
│ 2. Build Merkle proof path (simulated / fetched from indexer)
▼
Circom Circuit: RegistrationAuth(levels=20)
├── Identity template → verifies commitment derivation
└── MerkleTreeChecker → proves commitment is in the tree
│
│ 3. snarkjs.groth16.fullProve() — runs WASM witness gen + Groth16
▼
Groth16 Proof { pA, pB, pC }
Public signals { nullifierHash, merkleRoot }
│
│ 4. Submit to smart contract
▼
ZkRegistration.authenticate(pA, pB, pC, nullifierHash, merkleRoot)
├── Check merkleRoot == currentRoot
├── Check nullifierHash not already spent
├── Groth16Verifier.verifyProof() → on-chain pairing check
└── Mark nullifierHash as spent ✅ Access granted
zkp-registration/
│
├── circuits/ ← Circom ZK circuits
│ ├── identity.circom ← Standalone identity circuit (commitment + nullifier hash)
│ └── registration.circom ← Full auth circuit (identity + Merkle proof)
│
├── contracts/ ← Solidity smart contracts
│ ├── Verifier.sol ← Auto-generated Groth16 verifier (snarkjs export)
│ └── Registration.sol ← Business logic: root management, auth, replay protection
│
├── frontend/ ← Vite + React client-side prover UI
│ └── src/
│ ├── main.jsx ← React entry point
│ └── App.jsx ← ZK proof generation UI
│
├── test/
│ └── registration.test.js ← Hardhat integration test (proof → on-chain verification)
│
├── generate_input.js ← CLI: generate input.json for witness computation
├── client_prover.js ← CLI: generate proof off-chain (Node.js / ESM)
├── hardhat.config.js ← Hardhat configuration
└── package.json ← Root package (snarkjs, circomlib, hardhat)
| Layer | Technology |
|---|---|
| ZK Circuit | Circom 2.1.5 |
| ZK Proving | snarkjs (Groth16) |
| Hash Function | Poseidon (ZK-friendly) |
| Smart Contracts | Solidity 0.8.20 |
| Contract Dev | Hardhat |
| Frontend | Vite + React 19 |
| Frontend ZK | snarkjs (browser WASM) |
- Node.js ≥ 18
- npm ≥ 9
- Circom compiler (
circomCLI) — Install guide - snarkjs (installed automatically via npm)
# Clone the repository
git clone <repo-url>
cd zkp-registration
# Install root dependencies (circomlib, snarkjs, hardhat)
npm install
# Install frontend dependencies
cd frontend && npm install && cd ..# Compiles registration.circom → WASM witness generator + R1CS constraint system
circom circuits/registration.circom \
--r1cs --wasm --sym \
-o build/This produces:
build/registration.r1cs— constraint systembuild/registration_js/registration.wasm— witness generator (used in browser and CLI)
The .ptau and .zkey files are pre-committed in the repo for development. For production, run a proper ceremony:
# Phase 1: Universal SRS (already committed as pot14_final.ptau)
snarkjs powersoftau new bn128 14 pot14_0000.ptau
snarkjs powersoftau contribute pot14_0000.ptau pot14_0001.ptau --name="Contributor 1"
snarkjs powersoftau prepare phase2 pot14_0001.ptau pot14_final.ptau
# Phase 2: Circuit-specific setup
snarkjs groth16 setup build/registration.r1cs pot14_final.ptau registration_0000.zkey
snarkjs zkey contribute registration_0000.zkey registration_final.zkey --name="Contributor 1"
snarkjs zkey export verificationkey registration_final.zkey verification_key.json
# Export the Solidity verifier
snarkjs zkey export solidityverifier registration_final.zkey contracts/Verifier.sol# Step A: Generate the circuit input JSON
node generate_input.js
# → writes input.json
# Step B: Compute witness
node build/registration_js/generate_witness.js \
build/registration_js/registration.wasm \
input.json witness.wtns
# Step C: Generate Groth16 proof
snarkjs groth16 prove registration_final.zkey witness.wtns proof.json public.json
# Step D: Verify proof off-chain
snarkjs groth16 verify verification_key.json public.json proof.json
# Step E (optional): Generate proof entirely in Node.js
node client_prover.js# Run the Hardhat integration test (deploys contracts + submits proof)
npx hardhat testThe test in test/registration.test.js:
- Reads
proof.jsonandpublic.jsongenerated in step 3. - Deploys
Groth16VerifierandZkRegistrationto the local Hardhat network. - Calls
authenticate()with the proof and asserts it succeeds. - Attempts a replay attack and asserts it is rejected.
The frontend lets users generate a ZK proof in-browser by entering a secret PIN.
Required: Copy the WASM and zkey files to the frontend's public/ directory first:
cp build/registration_js/registration.wasm frontend/public/
cp registration_final.zkey frontend/public/Then start the dev server:
cd frontend
npm run dev
# → http://localhost:5173The UI:
- Accepts a numeric secret PIN.
- Derives
nullifierandtrapdoorfrom it. - Builds the Merkle path locally (simulated empty tree in dev).
- Generates a Groth16 proof using the WASM circuit in-browser.
- Displays the nullifier hash, Merkle root, and full proof JSON.
commitment = Poseidon(nullifier, trapdoor)
A one-way binding to the user's identity. Safe to store publicly on-chain.
nullifierHash = Poseidon(nullifier)
Revealed publicly during authentication. Prevents the same identity from authenticating twice (replay protection) without revealing which commitment was used.
A path from the commitment (leaf) to the Merkle root, proving membership without revealing which leaf. The Circom MerkleTreeChecker template recursively hashes path elements using Poseidon.
A succinct, non-interactive proof system. Produces a small constant-size proof (~200 bytes) regardless of circuit complexity. Requires a circuit-specific trusted setup.
| Risk | Mitigation |
|---|---|
| Replay attacks | usedNullifiers mapping prevents proof reuse |
| Merkle root staleness | authenticate() enforces _merkleRoot == currentRoot |
| Weak KDF in frontend | Demo only — use a proper KDF (e.g., PBKDF2, Argon2) in production |
| Trusted setup | Use a multi-party ceremony for production zkey |
| Centralised root update | updateRoot() is unrestricted — add access control in production |
| Secret exposure | Secrets never leave the client; only nullifierHash and root are public |
- Incremental Merkle Trees: Gas-efficient on-chain insertion using a Solidity Poseidon library.
- MPC Trusted Setup: Phase 2 ceremony with multiple independent contributors.
- Relayer Infrastructure: Gasless authentication by submitting proofs through a relayer.
- Indexer Integration: Fetch live Merkle tree state from an on-chain indexer rather than simulating.
- Proper KDF: Replace the linear secret derivation in the frontend with Argon2id or PBKDF2.
- Access Control on
updateRoot: Restrict root updates to an authorised operator or on-chain Merkle logic contract.