Skip to main content

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:

ArtefactWho uses itIn the repository
Circuit in WebAssembly (.wasm)The prover computes the witnesssemaphore-<depth>.wasm
Proving key (.zkey)The prover builds the proofsemaphore-<depth>.zkey
Verification keyThe verifier checks the proofBuilt 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 termMeaning
IdentityA secret held by one person. In v4 it is a key pair; the secret never leaves the device.
Identity commitmentA public hash of the identity's public key. It is what gets added to a group.
GroupAn on-chain Merkle tree of identity commitments. Its depth sets the maximum size (2^depth members).
Membership proofA proof that the prover knows the secret of one commitment in the group, without saying which.
ScopeA public value that names the context of a proof (for example "this credential, today").
NullifierA 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.
MessageA public value the proof commits to, so the proof cannot be reused with a different message.
Merkle root validityHow 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​

RuleValue or checkWhy
Who may admitThe wallet bound to the credential's holder in the HolderRegistry, or the credential's issuer while it is still a registered issuerThe holder controls access; the issuer can help
Who may remove a memberThe bound holder or the issuer (a removed issuer may still remove)Withdrawing access should stay possible
Credential stateMust exist and be valid in the StatusRegistry for admission and for proofsA revoked or suspended credential grants nothing
PauseAdmission and proofs stop while the IssuerRegistry is pausedSame emergency switch as issuance
Group sizeMAX_MEMBERS = 64; a removed member's slot is not freedKeeps every group within the served depth
Depth servedMAX_DEPTH_SERVED = 6The app ships circuit files for depths 1 to 6 only
Root validityGROUP_ROOT_VALIDITY = 10 minutesProofs made just before a membership change still pass
EpochWhole days (EPOCH_SECONDS = 1 days); a proof's epoch may be the current day, the day before or the day afterOne receipt per member per credential per day, with tolerance for clock skew
Scopekeccak256(abi.encode("certichain.access.v1", chainid, gate address, credentialId, epoch))Binds the proof to one chain, gate, credential and day
MessageThe caller's address as a numberA proof copied from the mempool cannot be submitted by someone else
NullifierRecorded per credential once usedA 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 AccessIdentity typed data twice (see Signatures). HKDF-SHA256 with the salt certichain.access-identity.v1 turns the signature into two 32-byte seeds, labelled semaphore-identity and x25519-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.

  1. 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).
  2. 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.
  3. It loads semaphore-<depth>.wasm and semaphore-<depth>.zkey from the same origin under /zk, with credentials omitted and redirects refused, and checks each file's SHA-256 against src/lib/zk/circuit-hashes.json. A file with any other hash is never used.
  4. 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:

CheckCommandWhat it shows
Pinned versions and lock filenpm run check:zk-pins (in blockchain/)The Semaphore and snarkjs versions are exact and match a reviewed snapshot
No mock verifiernpm run check:no-mock-verifierNo contract that always says "valid" can be deployed by mistake
No development keysnpm run check:no-dev-keysNo locally generated test keys are in the tree
Verification keys matchnpm run check:zk-keysThe verification keys inside all 32 pinned .zkey files (depths 1 to 32) match the verification key built into the pinned verifier contract
Reproductionnpm run zk:reproduceEvery pinned .zkey can be rebuilt from the circuit source against the Perpetual Powers of Tau
App circuit filesnpm 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
What these checks prove

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.sol
  • src/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.ts
  • src/services/domain/access-service.ts, src/services/domain/access-requester-service.ts
  • src/services/indexing/access-group-index.ts
  • blockchain/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.ts
  • scripts/zk-public.mjs

Tests​

  • blockchain/test/zk/AccessGate.test.ts, AccessGateProofs.test.ts, AccessJourney.test.ts, ProofCallGuard.test.ts
  • blockchain/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.ts
  • src/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