Zero-knowledge biometric identity on Ethereum. Prove you are human without revealing your face.
- What This Project Does
- System Architecture
- How Identity Is Created
- Zero-Knowledge Proof Flow
- Cross-Device Recovery
- Smart Contract
- ZK Circuit
- Relayer (Gas Abstraction)
- Frontend
- Security Properties
- Tech Stack
- Setup & Local Dev
- Deployment (Sepolia Testnet)
- Current State & Limitations
Identity Protocol lets a user prove "I am a real, unique human tied to this wallet" on-chain — without storing any biometric data anywhere.
The core guarantee: the system links your face to your wallet cryptographically. A different person cannot use your wallet, and you cannot use someone else's wallet.
What is NOT stored anywhere:
- Your face image
- Your face embeddings
- The random secret derived from your face
What IS stored on-chain (publicly visible but privacy-preserving):
commitment— a Poseidon hash of your identity (reveals nothing about face or secret)helperData— 96 bytes of BCH fuzzy-extractor data (reveals nothing about face by the fuzzy-extractor security proof)
┌─────────────────────────────────────────────────────────────┐
│ MODULE 1: Frontend (React) │
│ Face scan → MediaPipe landmarks → BCH fuzzy extractor → │
│ randomSecret → commitment → ZK proof generation │
└──────────────────────┬──────────────────────────────────────┘
│ HTTP (no biometric data sent)
┌──────────────────────▼──────────────────────────────────────┐
│ MODULE 2: Relayer (Rust / Axum) │
│ Receives commitment + helperData + ZK proof │
│ Submits transactions to Ethereum (pays gas) │
└──────────────────────┬──────────────────────────────────────┘
│ JSON-RPC
┌──────────────────────▼──────────────────────────────────────┐
│ MODULE 3: Smart Contract (Solidity / Foundry) │
│ PrivacyShield.sol — stores commitments, verifies ZK proofs │
│ Verifier.sol — auto-generated Groth16 verifier │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ MODULE 4: ZK Circuit (Circom / snarkjs) │
│ Proves: Poseidon(secretId) == commitment │
│ Proves: Poseidon(secretId, app, wallet) == nullifier │
│ Without revealing secretId │
└─────────────────────────────────────────────────────────────┘
All biometric computation happens locally in the browser. Nothing biometric is ever sent to the relayer or stored on-chain.
MediaPipe FaceMesh extracts 468 3D facial landmarks from the camera. These are converted into a 128-dimensional embedding (normalized float vector) and then binarized into 511 bits of face data.
face image → 468 landmarks → 128-dim embedding → 511 face bits
A BCH(511, 259, t=30) error-correcting code is used. This allows up to 30 bit-flips between scans of the same face (lighting, angle, expression changes) while still recovering the exact same secret.
randomSecret ──→ bchEncode(randomSecret) → 511-bit codeword
face bits XOR codeword ──→ helperData (64 bytes)
The randomSecret is a random 32-byte value generated at enrollment time. The helperData is the XOR of the face bits with the BCH codeword — it reveals nothing about either input (fuzzy-extractor security property).
A SHA-256 hash of the randomSecret is appended:
helperData = XOR_bits (64 bytes) || SHA256(randomSecret) (32 bytes) = 96 bytes total
walletKey = keccak256(walletAddress)
secretId = Poseidon(walletKey, randomSecret)
commitment = Poseidon(secretId)
The commitment is a Poseidon hash (ZK-friendly). It permanently binds the face (via randomSecret) to the wallet (via walletKey). Neither can produce the correct commitment alone.
The relayer submits registerIdentity(userWallet, commitment, helperData) to the contract. The contract stores the commitment and helperData on-chain, one per wallet.
The Groth16 circuit takes:
| Signal | Type | Description |
|---|---|---|
secretId |
private | Poseidon(walletKey, randomSecret) |
identityCommitment |
public | Poseidon(secretId) |
app_address |
public | Address of PrivacyShield contract |
user_wallet |
public | User's Ethereum address |
nullifier |
public | Poseidon(secretId, app_address, user_wallet) |
The circuit proves:
Poseidon(secretId) == identityCommitment— you know the secret behind the commitmentPoseidon(secretId, app_address, user_wallet) == nullifier— the nullifier is correctly derived
- secretId is never revealed — only its Poseidon hash (the commitment) is public
- app_address binding — the proof only works for the specific contract it was generated for; cannot be replayed on a different contract
- nullifier prevents replay — each proof produces a unique nullifier that is marked as used on-chain
- wallet binding — the nullifier includes the wallet address, so the proof cannot be submitted for a different wallet
1. registeredIdentities[commitment] == true (must be registered)
2. appAddress == address(this) (designated-verifier binding)
3. usedNullifiers[nullifier] == false (no replay)
4. verifier.verifyProof(a, b, c, publicSignals) (Groth16 pairing check)
5. Mark nullifier as used, emit ActionVerified
When a user switches devices or clears their browser storage, the helperData stored on-chain enables recovery:
1. Fetch helperData from contract: getProfile(walletAddress)
2. Scan face on new device → 511 face bits
3. XOR face bits with stored helperData → noisy codeword
4. BCH decode noisy codeword → recover randomSecret (if ≤30 bit errors)
5. Verify: SHA256(recovered) == stored SHA256 hint
6. Recompute secretId and commitment
If BCH decode fails (too many bit errors — wrong person or too different a scan), recovery fails and an error is shown. The threshold is t=30 bit errors.
File: contracts/privacy-shield/src/PrivacyShield.sol
mapping(uint256 => bool) public registeredIdentities; // commitment → registered
mapping(uint256 => bool) public usedNullifiers; // nullifier → used
mapping(uint256 => bool) public verifiedIdentities; // commitment → verified
mapping(address => IdentityProfile) public profiles; // wallet → profilestruct IdentityProfile {
uint256 commitment;
bytes helperData; // 96 bytes: XOR bits (64) || SHA256(secret) (32)
bool exists;
}| Function | Description |
|---|---|
registerIdentity(userWallet, commitment, helperData) |
Called by relayer to register a new identity |
verifyAndExecute(a, b, c, publicSignals) |
Verifies Groth16 proof, marks identity as verified |
getProfile(userWallet) |
Returns commitment, helperData, exists — used for cross-device recovery |
isRegistered(commitment) |
View helper |
- One wallet = one identity:
require(!profiles[userWallet].exists) - One commitment = one registration:
require(!registeredIdentities[commitment]) - No proof replay:
require(!usedNullifiers[nullifier])(hard revert) - Designated-verifier binding:
require(appAddress == uint256(uint160(address(this))))
File: circuits/privacy.circom
pragma circom 2.0.0;
include "circomlib/circuits/poseidon.circom";
template PrivacyIdentity() {
signal input secretId;
signal output identityCommitment;
signal output app_address;
signal output user_wallet;
signal output nullifier;
// Constraint 1: commitment = Poseidon(secretId)
component commitmentHash = Poseidon(1);
commitmentHash.inputs[0] <== secretId;
identityCommitment <== commitmentHash.out;
// Constraint 2: nullifier = Poseidon(secretId, app_address, user_wallet)
component nullifierHash = Poseidon(3);
nullifierHash.inputs[0] <== secretId;
nullifierHash.inputs[1] <== app_address;
nullifierHash.inputs[2] <== user_wallet;
nullifier <== nullifierHash.out;
}
component main {public [app_address, user_wallet]} = PrivacyIdentity();Proving system: Groth16 on BN128 (alt_bn128) elliptic curve
Artifacts:
circuits/circuit_final.zkey— proving key (trusted setup)circuits/circuit_vk.json— verification keycircuits/circuit.wasm— compiled circuit for browser proof generation (auto-copied toui/public/onnpm start)
Directory: relayer/
Language: Rust (Axum web framework, Alloy Ethereum library)
The relayer is a backend service that pays gas on behalf of users. Users never need ETH in their wallet to interact with the protocol.
| Method | Path | Description |
|---|---|---|
| GET | /health |
Health check |
| POST | /check-registration |
Check if a commitment is already registered |
| POST | /register |
Submit registerIdentity transaction |
| POST | /verify |
Submit verifyAndExecute transaction |
{ "identityCommitment": "12345..." }{
"identityCommitment": "12345...",
"helperData": "0xaabbcc...",
"userWallet": "0xAbCd..."
}{
"proof": { "pi_a": [...], "pi_b": [...], "pi_c": [...] },
"publicSignals": ["commitment", "appAddress", "wallet", "nullifier"]
}Set in relayer/.env:
PRIVATE_KEY=0x... # Relayer's funded wallet
RPC_URL=https://... # Ethereum RPC (Alchemy/Infura)
CONTRACT_ADDRESS=0x... # PrivacyShield contract address
PORT=3001Directory: ui/
Framework: React (Create React App)
ui/src/
├── App.js # Main app, orchestrates flow
├── hooks/
│ ├── useBiometric.js # Core: face scan, BCH, ZK proof
│ ├── useWallet.js # MetaMask connection
│ └── useRegistration.js # Registration flow state
├── services/
│ ├── fuzzyExtractor.js # BCH encode/decode, SHA-256 check
│ ├── relayerService.js # HTTP calls to relayer
│ └── embeddingService.js # Face embedding → 511 bits
├── utils/
│ ├── contract.js # ethers.js ABI + getProfile()
│ └── validators.js # Input validation
└── components/
└── FaceScanner.jsx # Camera + MediaPipe UI component
1. Connect MetaMask wallet
2. FaceScanner: MediaPipe captures landmarks (30 frames averaged)
3. Liveness check: nose-tip movement detected (anti-spoof)
4. useBiometric: compute embedding → 511 bits → BCH → randomSecret
5. Compute secretId = Poseidon(walletKey, randomSecret)
6. Compute commitment = Poseidon(secretId)
7. Generate Groth16 proof in browser (snarkjs)
8. Send commitment + helperData to relayer /register
9. Emit IdentityRegistered event on-chain
1. Load existing profile (localStorage or on-chain via getProfile)
2. Scan face again → recover randomSecret via BCH
3. Verify SHA-256 matches stored hint
4. Recompute secretId and commitment
5. Generate Groth16 proof with nullifier
6. Send proof to relayer /verify
7. Contract emits ActionVerified
Before BCH recovery is accepted, cosine similarity between the current face embedding and the stored descriptorTemplate is checked:
const DIFFERENT_PERSON_THRESHOLD = 0.92;
// If cosine similarity < 0.92 → reject as different personThis prevents a different person from successfully recovering a registered wallet's secret even if their BCH bit errors happen to be under 30.
| Property | Mechanism |
|---|---|
| Face privacy | BCH fuzzy extractor — helperData is information-theoretically independent of face bits |
| Secret privacy | ZK proof — secretId never leaves the browser |
| Commitment binding | Poseidon hash is collision-resistant |
| Replay prevention | Nullifier = Poseidon(secretId, app, wallet) — unique per (identity, app, wallet) tuple |
| Cross-app isolation | app_address in nullifier — same identity produces different nullifier for each app |
| Cross-wallet isolation | user_wallet in commitment — face + wrong wallet cannot produce correct commitment |
| Attack | Defense |
|---|---|
| Different person using your wallet | Cosine similarity check (threshold 0.92) + BCH bit error limit (t=30) |
| Replay proof on same contract | usedNullifiers mapping — hard revert |
| Replay proof on different contract | app_address binding in Groth16 circuit |
| Register same commitment twice | require(!registeredIdentities[commitment]) |
| Register two identities to one wallet | require(!profiles[userWallet].exists) |
| Front-run registration | No msg.sender check in registerIdentity (known limitation for mini-project scope) |
registerIdentity does not verify msg.sender == userWallet. A malicious actor who observes a pending transaction in the mempool could front-run and register the commitment to their own wallet. Mitigation would require the user to sign the registration data (EIP-712 signature check in the contract).
| Layer | Technology |
|---|---|
| Face landmarks | MediaPipe FaceMesh (browser, WASM) |
| Face embedding | Custom 128-dim from 468 landmarks |
| Error correction | BCH(511, 259, t=30) |
| Hash function | Poseidon (ZK-friendly), SHA-256 (recovery hint), keccak256 (wallet key) |
| ZK proving system | Groth16 (snarkjs, browser) |
| ZK circuit language | Circom 2.0 |
| Smart contract | Solidity ^0.8.20 |
| Contract framework | Foundry (forge, cast) |
| Blockchain | Ethereum (Sepolia testnet) |
| Relayer | Rust, Axum, Alloy |
| Frontend | React, ethers.js |
| Elliptic curve | BN128 (alt_bn128) |
- Node.js ≥ 18
- Rust + Cargo
- Foundry (
curl -L https://foundry.paradigm.xyz | bash) - MetaMask browser extension
Branch:
final— always pull from this branch.
git clone https://github.com/Ultr0nX/Identity-protocol.git
cd Identity-protocol
git checkout finalcd ui
npm install
npm start- Opens at http://localhost:3000
- Automatically copies
circuit.wasmtoui/public/on start
cd relayer
cp .env.example .envNow open relayer/.env and fill in:
PRIVATE_KEY=0x<your_funded_sepolia_wallet_private_key>
RPC_URL=https://eth-sepolia.g.alchemy.com/v2/<your_alchemy_key>
CONTRACT_ADDRESS=0x99C9aBccAF1aed42Db8eE5e07d313EF8A470c79BThen run:
cargo run- Relayer starts at http://localhost:3001
- Uses Sepolia testnet (chainId 11155111)
- Contract is already deployed — no redeployment needed
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env
# Then run the relayer
cd relayer
cargo run| Problem | Fix |
|---|---|
git add . hangs or index.lock error |
Run rm .git/index.lock then retry |
| Port 3001 already in use | Run lsof -ti :3001 | xargs kill -9 then cargo run |
| MetaMask not connecting | Switch MetaMask network to Sepolia Testnet |
| Face scan stuck at 0/30 | Allow camera permission in browser, use Chrome |
| BCH mismatch on Verify | Open browser console → localStorage.removeItem('privacy-shield.embedding-profile.v3') → refresh |
git clone https://github.com/Ultr0nX/Identity-protocol.git
cd Identity-protocol
git checkout finalcd ui
npm install
npm start
# Runs on http://localhost:3000
# Automatically copies circuit.wasm to ui/public/cd relayer
cp .env.example .env
# Fill in PRIVATE_KEY, RPC_URL, CONTRACT_ADDRESS
cargo run
# Runs on http://localhost:3001cd contracts/privacy-shield
forge install
forge build
forge script script/Deploy.s.sol --rpc-url $RPC_URL --broadcastcd circuits
npm install
circom privacy.circom --r1cs --wasm --sym
snarkjs groth16 setup privacy.r1cs pot12_final.ptau circuit_0.zkey
snarkjs zkey contribute circuit_0.zkey circuit_final.zkey
snarkjs zkey export verificationkey circuit_final.zkey circuit_vk.json
snarkjs zkey export solidityverifier circuit_final.zkey Verifier.sol| Component | Address / URL |
|---|---|
| PrivacyShield contract | 0x99C9aBccAF1aed42Db8eE5e07d313EF8A470c79B |
| Network | Ethereum Sepolia (chainId 11155111) |
| Relayer | http://localhost:3001 (local, run separately) |
| Frontend | http://localhost:3000 (local dev) |
The Verifier.sol contract is deployed separately and its address is passed to the PrivacyShield constructor.
- Full registration flow: face scan → BCH → ZK proof → on-chain commitment
- Full verification flow: face rescan → BCH recovery → ZK proof → on-chain nullifier
- Cross-device recovery via on-chain helperData
- Gas abstraction: users pay no ETH, relayer submits all transactions
- Different-person rejection: cosine similarity guard at the UI layer
- One wallet = one identity enforced at both contract level and UI level
| Issue | Impact | Status |
|---|---|---|
| Front-run registration attack | Attacker can steal a commitment by front-running | Known, deferred |
| No msg.sender == userWallet check | Relayer could register wrong wallet | Deferred (trusted relayer assumed) |
| Liveness is movement-based | Not robust against video replay | Mini-project scope |
| Trusted setup (Groth16) | Requires ceremony for production | Mini-project, test zkey used |
| Single relayer | Central point of failure | Mini-project scope |
| helperData public | XOR of face bits — future work: encrypt with wallet key | Enhancement opportunity |
- ✅ Double registration (same commitment) — blocked by contract
- ✅ Double registration (same wallet) — blocked by contract
- ✅ Proof replay (same nullifier) — hard revert in contract
- ✅ Cross-app proof replay — app_address binding in circuit
- ✅ Wrong person using registered wallet — cosine similarity + BCH error limit
- ✅ Fake commitment (no ZK proof) — verifyProof pairing check
- ✅ Wrong wallet in proof — wallet included in nullifier computation
- ✅ Tampered public signals — Groth16 would fail
- ✅ BCH collision (different face, same codeword) — SHA-256 commitment check
- ✅ On-chain data reveals face — fuzzy-extractor security proof
- ✅ Commitment reveals secret — Poseidon preimage resistance
⚠️ Front-run registration — no msg.sender check (known limitation)- ✅ Nullifier linkage across apps — different nullifier per app_address
- ✅ Recovery by wrong person — BCH t=30 + cosine 0.92 threshold
- ✅ secretId extraction from proof — ZK guarantee (private input)
This is a mini-project / proof-of-concept. Not audited for production use.