Skip to main content

Private access proofs (ZK)

The access gate lets a credential's holder (or its issuer) say "this person may see my credential", and lets that person later prove "I am one of the people admitted to this credential" without revealing which one. The proof is checked on chain and leaves a receipt event. It is built on Semaphore v4, an audited zero-knowledge group membership protocol.

A proof is a receipt, not a key

Proving access never releases a document, a key or a CID. It records that some admitted member proved on a given day. To actually see the document the holder still shares it (a grant file or a link, see Sharing). The /access page says this after every proof.

Who takes part​

ActorWhat they doWallet
Requester (for example an employer)Makes an access identity, sends the holder an access code, later proves access and sends the proofA wallet account to send the proof transaction; the identity can come from that wallet or from a file
HolderAdmits or removes access codes for their credentialYes, the wallet bound to the student in the HolderRegistry
IssuerCan also admit and remove, for credentials it issuedYes, the issuing key, which must still be registered to admit
AnyoneReads receipts from the chainNo

Preconditions​

  • An access gate is deployed for the chain and listed in src/contracts/zk/deployment.<chainId>.json. On Sepolia the gate is 0xFd75Ee64dA6127BA4D61799920291E5016518af6 and it serves the CredentialRegistry of set 0.3.0 (0x53Fb56822E507bAf5c0221D993108495CE1931ea).
  • A gate serves one credential registry for good. The access controls appear only for credentials of that registry's set (useAccessSupport compares the credential's set registry with servedRegistry()). Credentials of other sets show no access controls.
  • The credential is valid (StatusRegistry.isValid), and the platform is not paused (IssuerRegistry.paused() is false) for admitting and proving.
  • The circuit files public/zk/semaphore-<depth>.wasm and .zkey for depths 1 to 6 are present. npm run zk:public places them there before dev and build and checks each against src/lib/zk/circuit-hashes.json.

Concepts used​

TermMeaning here
Access identityA Semaphore identity (secret) plus an X25519 key pair, both derived from two 32-byte seeds.
Identity commitmentThe public number that represents the identity inside a group (an element of the BN254 scalar field).
Access codeccaccess1_ followed by base64url of: version byte, 32-byte commitment, 32-byte X25519 public key, 4-byte checksum. What the requester gives the holder.
Fingerprint16 hex digits in groups of four, a SHA-256 of the commitment and public key under a domain string. Both people compare it over another channel before admitting.
GroupOne Semaphore group per credential, created by the gate on the first admission. The gate is the group's only admin.
EpochWhole days since 1970 in UTC: block.timestamp / 1 days.
Scopekeccak256(abi.encode("certichain.access.v1", chainId, gate, credentialId, epoch)). Binds a proof to one chain, gate, credential and day.
MessageThe address that will send the proof, as a number. Binds a proof to its sender.
NullifierA value derived from the identity and the scope. The same identity always produces the same nullifier for the same credential and day, so it can prove at most once per day per credential.

Step 1: the requester makes an access identity​

The requester opens /access ("Prove access to a credential"). Under Step 1, Your access identity there are three ways to get one:

ButtonHow the seeds are madeRecoverable by
Use my walletThe wallet signs typed data AccessIdentity { owner, keyVersion } (domain name Certichain keys, version 1, the chain id) twice; the app checks both signatures are identical and recover to the account, then derives two seeds from the signature with HKDF-SHA256 (salt certichain.access-identity.v1, info labels semaphore-identity and x25519-key plus the owner and key version).Signing the same message again with the same account
Make a new identity32 random bytes for each seed.Only the downloaded identity file
Or load an identity file you savedReads a certichain-access-identity version 1 file holding the two seeds in base64url.The file itself

A warning on the page explains that the AccessIdentity signature is a secret: any site that asks for the same message can make the same identity. A contract wallet cannot be used for Use my wallet (its signature is not a repeatable secret), and neither can a wallet that does not sign deterministically; both get a message telling the requester to make a new identity and keep its file.

The page then shows the access code (with Copy access code) and its fingerprint. A new identity also offers its file for download, with the reminder that anyone holding the file can prove access as this identity.

Step 2: the holder or issuer admits the code​

On /student (the Manage access button on a credential row in the holder's list) or /dashboard (the row action Manage access), the access panel Who can prove access opens. It lists members and receipts and has an admit form.

  1. The requester sends the access code by any channel.
  2. The admitter pastes it into Requester's access code. The form parses it locally: a wrong prefix, length or checksum gives a readable error ("This access code has a typing mistake. Copy the whole code again."). A valid code shows "Check this with the person:" and the fingerprint.
  3. Both people compare the fingerprint over another channel, then the admitter clicks Admit and confirms the transaction CertichainAccessGate.admit(credentialId, identityCommitment).
  4. The panel reports "Admitted. The person can now prove access with this code."

The gate checks, in order: not paused; the credential exists; the caller is the issuer and still a registered issuer, or the caller is the wallet HolderRegistry.holderOf(issuer, subjectCommitment) binds to the student; the credential is valid; the commitment is not zero; the group has fewer than 64 leaves. The first admission creates the group (semaphore.createGroup(gate, 10 minutes)). Each admission emits MemberAdmitted(credentialId, groupId, identityCommitment, actor).

Capacity. A credential admits at most 64 commitments over its whole life. Removing a member keeps its leaf slot (Semaphore replaces it with zero), so removals do not free capacity. The panel says so when the group is full.

Removing. Each member row has a remove link that opens Remove this access code? with Remove access. The app rebuilds the group from MemberAdmitted and MemberRemoved logs, checks that the rebuilt root equals Semaphore.getMerkleTreeRoot(groupId) on chain, computes the Merkle siblings and calls removeMember. The holder or the issuer may remove even while the platform is paused or after the credential stopped being valid. A removed issuer can still remove members but cannot admit new ones.

Step 3: the requester proves access​

Under Step 2, Prove access the requester enters the Credential id and clicks Prove access. A progress line follows four stages: Group, Files, Proof, Send.

Details of each stage:

  1. Group (AccessService.prepareProof). The connected account becomes the proof's caller. The service reads the group history from logs and refuses if the rebuilt root differs from the chain's root. If the identity's commitment is not in the group: "This identity is not admitted to this credential, or was removed. Ask the holder to admit your access code." It reads currentEpoch() and scopeOf(credentialId, epoch) from the gate and compares the gate's scope with its own computation; a difference stops the flow.
  2. Files. Proving runs in a Web Worker (src/workers/access-prover.worker.ts), a fresh one per proof, terminated as soon as the proof settles so the identity does not outlive it. The worker fetches the two circuit files for the group's depth (minimum 1, maximum 6) from the same origin, with credentials: "omit" and redirect: "error", and computes their SHA-256. Any file whose hash differs from circuit-hashes.json is never used ("The proving file ... does not match the reviewed copy, so no proof was made.").
  3. Proof. generateProof from @semaphore-protocol/proof (which uses snarkjs) makes a Groth16 proof with the identity, the group, the message (caller address) and the scope. src/lib/zk/prove-access.ts is the only module of the web app that imports the proof package, and it is reached only through the worker. Errors leaving the worker are reduced to messages written for people, so nothing in an error can carry the identity.
  4. Send (AccessService.submitProof). The app refuses to send a proof made for another account ("This proof was made for ... Switch your wallet to that account"). The wallet then sends proveAccess(credentialId, epoch, proof).

The page shows "Receipt recorded for (UTC), sent from

. Its one-use number is ." and reminds the requester that the receipt does not open the document. A Cancel button aborts the worker while files load and while the proof is made.

What the gate checks​

CheckError
Platform not pausedAccessGate__Paused
Credential exists and is validAccessGate__UnknownCredential, AccessGate__CredentialNotValid
The credential has a groupAccessGate__NoGroup
epoch is within one day of the current epochAccessGate__EpochOutOfWindow
Proof depth at most 6AccessGate__DepthNotServed
proof.scope == scopeOf(credentialId, epoch)AccessGate__WrongScope
proof.message == uint160(msg.sender)AccessGate__WrongMessage
Nullifier not yet spent for this credentialAccessGate__NullifierSpent
Semaphore pairing check passes (the root must be the current root or one replaced less than 10 minutes ago)AccessGate__InvalidProof or a Semaphore error

src/lib/zk/gate-errors.ts turns these revert names into sentences for the page.

The gate keeps its own nullifier ledger instead of relying on Semaphore's validateProof. That library function can be called by anyone, so a bystander could copy a proof from the mempool and burn its nullifier before the receipt was recorded. For the same reason, consumers must trust only the gate's AccessProven event, never Semaphore's ProofValidated.

verifyAccess(credentialId, epoch, caller, proof) is a view that runs the same checks except the nullifier ledger and works while paused. It reverts rather than returning false for most bad inputs, so a calling contract must use try/catch.

Receipts​

AccessProven(credentialId, epoch, nullifier, caller) is the receipt. The access panel lists receipts for a credential, newest first, from a topic-filtered log scan (receiptTopics). A receipt says that some admitted member proved on that day and which account sent it; it does not say which member. Because the sending account is public, the page advises using a fresh account for anyone who does not want their proofs tied to their usual address.

Privacy properties and limits​

  • The proof hides which admitted member proved. It does not hide the sending account, the credential id or the day.
  • One identity proves at most once per credential per day (one nullifier per scope).
  • The page reads the chain through the connected node and trusts it to show the real chain, as the page's side note says.
  • At most 64 admissions per credential, ever.
  • The browser bundle includes snarkjs, which is licensed GPL-3.0 (accepted in ADR-0072 of the handbook).
  • The handbook records a measured browser proof time of about 1.2 to 1.7 seconds at depth 1 and about 1.2 seconds at depth 6 in Chromium; real devices vary.

Command-line tool​

blockchain/scripts/access.ts (npm run access in blockchain/) does the requester side without a browser:

CommandEffect
access identity new <file>Writes a new identity file (refuses to overwrite, mode 0600) and prints the access code and fingerprint
access identity show <file>Prints the access code and fingerprint of a saved identity
access prove --identity <file> --credential <id> --caller <address> --rpc <url> --out <file>Reads the gate manifest for the RPC's chain, builds the group from logs and writes a proof file
access submit --proof <file> --rpc <url>Sends the proof with the key in ACCESS_SUBMIT_KEY (read once, then removed from the environment) and prints the block and transaction

Error output replaces any URL with [node address], because node addresses often carry access tokens.

Code map​

  • Contract: blockchain/contracts/zk/CertichainAccessGate.sol, blockchain/contracts/zk/interfaces/ICertichainAccessGate.sol, blockchain/contracts/zk/SemaphoreImports.sol
  • Identity and codes: src/lib/zk/access-identity.ts, src/lib/zk/access-code.ts
  • Scope, groups, receipts, errors: src/lib/zk/scope.ts, src/lib/zk/group-events.ts, src/lib/zk/receipts.ts, src/lib/zk/gate-errors.ts, src/lib/zk/errors.ts
  • Proving: src/lib/zk/prove-access.ts, src/lib/zk/circuit-files.ts, src/lib/zk/circuit-hashes.json, src/lib/zk/prover-handler.ts, src/services/zk/access-prover.ts, src/services/zk/browser-worker.ts, src/workers/access-prover.worker.ts, scripts/zk-public.mjs
  • Services: src/services/domain/access-service.ts, src/services/domain/access-requester-service.ts, src/services/credentials/access-gateway.ts, src/services/indexing/access-group-index.ts
  • Pages and panels: src/app/access/ (AccessRequestView.tsx, IdentitySection.tsx, ProveSection.tsx, stage-text.ts), src/components/access/ (AccessPanel.tsx, AdmitForm.tsx, MemberList.tsx, ReceiptList.tsx, useAccessSupport.ts), src/app/dashboard/IssuerAccessSection.tsx, src/app/student/CredentialRow.tsx
  • Deployment: src/contracts/zk/deployment.11155111.json, blockchain/scripts/deploy-access-gate.ts, blockchain/scripts/verify-access-gate.ts, blockchain/scripts/access.ts, blockchain/scripts/lib/access-cli.ts
  • Design: docs/specs/P2-S04-zk-access-spec.md, docs/specs/P2-S07-access-gate.md

Tests​

  • Contract: blockchain/test/zk/AccessGate.test.ts, AccessGateProofs.test.ts (real proofs), AccessJourney.test.ts, AccessCli.test.ts, AccessBrowserProver.test.ts, ProofCallGuard.test.ts, DeployAccessGate.test.ts; see ZK tests
  • Unit: src/lib/zk/*.test.ts (codes, identities, circuit files, scope, groups, receipts, prover boundary), src/services/domain/access-service.test.ts, access-requester-service.test.ts, src/services/zk/access-prover.test.ts, src/components/access/AccessPanel.test.tsx, useAccessSupport.test.tsx, src/app/access/*.test.tsx
  • End to end: e2e/journeys/access-proof.journey.ts (a requester proves with a browser proof and the issuer sees the receipt; a requester never admitted gets a plain refusal and nothing is sent), e2e/journeys/access-proof-depth6.journey.ts (the deepest served group)