Zero-knowledge proofs
A zero-knowledge proof convinces a verifier that a statement is true without revealing why it is true. Certichain uses one in a single place: the access gate, where a party the holder has admitted proves "I am one of the people admitted to this credential" without revealing which one. The proof gives a receipt on chain. It never releases a document, a key or a CID.
This page explains the ideas, the Semaphore protocol that Certichain adopts, the trusted setup behind it, and how the repository pins and checks the circuit files.
The idea in brief
A zero-knowledge proof system has three properties:
- Completeness: an honest prover with a true statement convinces the verifier.
- Soundness: nobody can convince the verifier of a false statement, except with negligible probability.
- Zero knowledge: the verifier learns nothing beyond the fact that the statement is true.
A zk-SNARK (succinct non-interactive argument of knowledge) adds two practical properties: the proof is small and quick to check, and it is a single message, so a smart contract can verify it.
The statement is written as an arithmetic circuit. The prover's private inputs (the witness) satisfy the circuit; some inputs are public signals the verifier also sees.
Groth16
Groth16 is a zk-SNARK with very small proofs (three curve points) and cheap on-chain verification. Semaphore uses Groth16 over the BN254 curve, which Ethereum supports with built-in precompiles.
Groth16 needs, for each circuit:
| Artefact | Who uses it | In the repository |
|---|---|---|
Circuit in WebAssembly (.wasm) | The prover computes the witness | semaphore-<depth>.wasm |
Proving key (.zkey) | The prover builds the proof | semaphore-<depth>.zkey |
| Verification key | The verifier checks the proof | Built into the Semaphore verifier contract |
The proving and verification keys come from a trusted setup, explained below.
Semaphore v4
Semaphore is an open-source protocol by the Privacy and Scaling Explorations team for anonymous group membership with double-signalling protection. Certichain pins version 4.14.3 of the @semaphore-protocol/* packages and snarkjs 0.7.5 in both package.json and blockchain/package.json.
| Semaphore term | Meaning |
|---|---|
| Identity | A secret held by one person. In v4 it is a key pair; the secret never leaves the device. |
| Identity commitment | A public hash of the identity's public key. It is what gets added to a group. |
| Group | An on-chain Merkle tree of identity commitments. Its depth sets the maximum size (2^depth members). |
| Membership proof | A proof that the prover knows the secret of one commitment in the group, without saying which. |
| Scope | A public value that names the context of a proof (for example "this credential, today"). |
| Nullifier | A public value computed from the scope and the identity secret. The same identity in the same scope always gives the same nullifier, so a contract can refuse a second use, yet the nullifier reveals nothing about which member made it. |
| Message | A public value the proof commits to, so the proof cannot be reused with a different message. |
| Merkle root validity | How long an old root stays acceptable after the group changes, so proofs made just before a change still verify. |
The Certichain access gate
blockchain/contracts/zk/CertichainAccessGate.sol is a thin contract in front of the Semaphore contracts. It owns one Semaphore group per credential and decides who may add members.
Rules the gate enforces
| Rule | Value or check | Why |
|---|---|---|
| Who may admit | The wallet bound to the credential's holder in the HolderRegistry, or the credential's issuer while it is still a registered issuer | The holder controls access; the issuer can help |
| Who may remove a member | The bound holder or the issuer (a removed issuer may still remove) | Withdrawing access should stay possible |
| Credential state | Must exist and be valid in the StatusRegistry for admission and for proofs | A revoked or suspended credential grants nothing |
| Pause | Admission and proofs stop while the IssuerRegistry is paused | Same emergency switch as issuance |
| Group size | MAX_MEMBERS = 64; a removed member's slot is not freed | Keeps every group within the served depth |
| Depth served | MAX_DEPTH_SERVED = 6 | The app ships circuit files for depths 1 to 6 only |
| Root validity | GROUP_ROOT_VALIDITY = 10 minutes | Proofs made just before a membership change still pass |
| Epoch | Whole days (EPOCH_SECONDS = 1 days); a proof's epoch may be the current day, the day before or the day after | One receipt per member per credential per day, with tolerance for clock skew |
| Scope | keccak256(abi.encode("certichain.access.v1", chainid, gate address, credentialId, epoch)) | Binds the proof to one chain, gate, credential and day |
| Message | The caller's address as a number | A proof copied from the mempool cannot be submitted by someone else |
| Nullifier | Recorded per credential once used | A member cannot produce two receipts for the same credential and day |
function scopeOf(bytes32 credentialId, uint256 epoch) public view returns (uint256) {
return uint256(keccak256(abi.encode(SCOPE_DOMAIN, block.chainid, address(this), credentialId, epoch)));
}
The gate keeps its own record of spent nullifiers rather than relying on the Semaphore library's public validation function, which anyone can call; the contract comment explains that relying on the library would let a bystander use up a member's nullifier with a copied proof before the receipt is recorded.
verifyAccess is a read-only version of the same checks for off-chain callers; accessGroupOf returns the group id, its current size and the slots used.
What a receipt does and does not say
AccessProven(credentialId, epoch, nullifier, caller) says that some admitted identity proved membership for that credential on that day, and which address sent the proof. It does not say which admitted identity it was. Because the message is the sender's address, the sender's address is public; requesters who want to stay unlinkable to their usual wallet can send from a separate account. See Privacy design.
Access identities
An access identity is a Semaphore identity plus an X25519 key pair (src/lib/zk/access-identity.ts). It can be made two ways:
- From a wallet: the wallet signs
AccessIdentitytyped data twice (see Signatures). HKDF-SHA256 with the saltcertichain.access-identity.v1turns the signature into two 32-byte seeds, labelledsemaphore-identityandx25519-key. Signing again recovers the same identity, so nothing is stored. - Random, for a requester without a wallet. The two seeds are then saved in a file of format
certichain-access-identity; the page warns that the file holds secrets in the clear.
The access code the requester hands to the holder is ccaccess1_ followed by base64url of a version byte, the 32-byte identity commitment, the 32-byte X25519 public key and a 4-byte checksum (src/lib/zk/access-code.ts). The X25519 key is there so that a holder can send the requester a grant file after seeing a receipt, as the access design describes; today the app uses it to show a fingerprint when admitting and does not build a grant from an access code.
Proving in the browser
src/lib/zk/prove-access.ts builds the proof. It is the only module of the web app that imports the Semaphore proof package and, through it, snarkjs, and the page reaches it only through a Web Worker (src/lib/zk/prover-handler.ts), so proving does not freeze the page.
- The requester's browser rebuilds the credential's group from the gate's events (
src/lib/zk/group-events.ts,src/services/indexing/access-group-index.ts). - It refuses early if the identity is not in the group ("This identity is not admitted to this credential, or was removed. Ask the holder to admit your access code.") or if the group is deeper than the gate serves.
- It loads
semaphore-<depth>.wasmandsemaphore-<depth>.zkeyfrom the same origin under/zk, with credentials omitted and redirects refused, and checks each file's SHA-256 againstsrc/lib/zk/circuit-hashes.json. A file with any other hash is never used. - It generates the proof with the scope for the current epoch and the connected account as message, then the wallet sends
proveAccess.
The handbook records measured proving times of about 1.2 to 1.7 seconds at depth 1 in Chromium and about 1.2 seconds at depth 6. The full flow, including the /access page, is in ZK access.
Trusted setup
Groth16's keys are produced in a setup ceremony. The ceremony creates secret randomness (sometimes called "toxic waste"); anyone who knew all of it could forge proofs. A multi-party ceremony makes forgery require every participant to have kept or leaked their share, so one honest participant is enough.
Setup has two phases:
- Phase 1, Powers of Tau: universal, reusable by any circuit up to a size. Semaphore builds on the community Perpetual Powers of Tau.
- Phase 2: specific to each circuit, producing that circuit's
.zkey.
Certichain does not run its own ceremony. It uses the Semaphore v4 artefacts and checks that they are consistent:
| Check | Command | What it shows |
|---|---|---|
| Pinned versions and lock file | npm run check:zk-pins (in blockchain/) | The Semaphore and snarkjs versions are exact and match a reviewed snapshot |
| No mock verifier | npm run check:no-mock-verifier | No contract that always says "valid" can be deployed by mistake |
| No development keys | npm run check:no-dev-keys | No locally generated test keys are in the tree |
| Verification keys match | npm run check:zk-keys | The verification keys inside all 32 pinned .zkey files (depths 1 to 32) match the verification key built into the pinned verifier contract |
| Reproduction | npm run zk:reproduce | Every pinned .zkey can be rebuilt from the circuit source against the Perpetual Powers of Tau |
| App circuit files | npm run zk:public (repository root) | Copies the depth 1 to 6 files into public/zk, refusing any file whose hash differs from circuit-hashes.json |
They show that the keys the app uses are the published Semaphore keys and that those keys follow from the circuit source and the public Powers of Tau. They do not show that any ceremony participant was honest; that rests on the ceremony itself. The handbook lists the ceremony outcome page and the Semaphore v4 audit report as items still to be located for the independent review.
Source files
blockchain/contracts/zk/CertichainAccessGate.solsrc/lib/zk/access-identity.ts,access-code.ts,scope.ts,prove-access.ts,prover-handler.ts,circuit-files.ts,circuit-hashes.json,group-events.ts,receipts.tssrc/services/domain/access-service.ts,src/services/domain/access-requester-service.tssrc/services/indexing/access-group-index.tsblockchain/scripts/check-zk-pins.ts,check-no-mock-verifier.ts,check-no-dev-keys.ts,check-zk-keys.ts,reproduce-zk-setup.ts,fetch-zk-artifacts.tsscripts/zk-public.mjs
Tests
blockchain/test/zk/AccessGate.test.ts,AccessGateProofs.test.ts,AccessJourney.test.ts,ProofCallGuard.test.tsblockchain/test/zk/SemaphoreProperties.test.ts,SemaphoreVectors.test.ts,VerificationKey.test.ts,KeyCheck.test.ts,SetupReproduction.test.ts,ZkPins.test.ts,MockVerifierScan.test.ts,DevKeyFiles.test.ts,ZkArtifacts.test.tssrc/lib/zk/access-identity.test.ts,access-code.test.ts,scope.test.ts,prove-access.test.ts,prover-handler.test.ts,circuit-files.test.ts,group-events.test.ts,receipts.test.ts,proof-boundary.test.ts- E2E journeys
e2e/journeys/access-proof.journey.ts,access-proof-depth6.journey.ts