Skip to main content

Access gate

CertichainAccessGate lets a holder (or the issuer) admit parties, such as an employer's verification desk, to a credential's access group, and lets an admitted party later prove "I am one of the parties admitted to this credential" without revealing which one. A valid proof produces an AccessProven receipt on the chain.

A proof is a receipt, nothing more

A proof never releases a key or a document. Documents are opened with keys the holder shares through the sharing flow. The gate only records that some admitted party proved membership for a credential in a given epoch.

The cryptography (identity commitments, Merkle groups, nullifiers, Groth16 proofs) is Semaphore v4, pinned at version 4.14.3. The gate is a thin contract in front of it that decides who may admit, which credentials qualify and what a proof must be bound to.

Dependencies and constructor​

uint256 public constant EPOCH_SECONDS = 1 days;
uint256 public constant GROUP_ROOT_VALIDITY = 10 minutes;
uint256 public constant MAX_MEMBERS = 64;
uint256 public constant MAX_DEPTH_SERVED = 6;
string public constant SCOPE_DOMAIN = "certichain.access.v1";

Constructor: (ICredentialRegistry credentials_, ISemaphore semaphore_). Zero addresses revert AccessGate__ZeroAddress(). The gate reads issuerRegistry(), statusRegistry() and holderRegistry() from the credential registry once and stores all five as immutables. One gate serves one contract set.

ContractSourceRole
Semaphore@semaphore-protocol/contracts 4.14.3, imported through blockchain/contracts/zk/SemaphoreImports.solGroups, member trees, proof verification
SemaphoreVerifierSame packageThe Groth16 verifier for tree depths 1 to 32
PoseidonT3poseidon-solidity, deployed at its published deterministic addressHash library linked into Semaphore

The gate imports only interfaces from the v0.3 folder and compiles under the default (Paris) profile, like Semaphore.

Deployment on Sepolia​

From src/contracts/zk/deployment.11155111.json (deployed 2026-10-16, block 11924210, abiVersion 0.3.0-access.1):

ContractAddress
CertichainAccessGate0xFd75Ee64dA6127BA4D61799920291E5016518af6
Semaphore0xF5B3531820E0fc5d77DEE52cbf3BBF82B7c14A8E
SemaphoreVerifier0xF562A9A42B6cDA79fd2d1f13699486251473b001
PoseidonT30x3333333C0A88F9BE4fd23ed0536F9B6c427e3B93
Credential registry served0x53Fb56822E507bAf5c0221D993108495CE1931ea (set 0.3.0)

The manifest also records the verifier and Poseidon code hashes, which the deploy script checks against pinned values.

Storage in words​

MappingMeaning
_groupPlusOne[credentialId]The Semaphore group id plus one (zero means "no group yet", because Semaphore numbers groups from zero)
_size[credentialId]Current number of members (admitted minus removed)
_spent[credentialId][nullifier]Nullifiers this gate has accepted

The gate keeps its own nullifier ledger on purpose. Semaphore's validateProof can be called by anyone, so relying on it would let a bystander copy a member's proof from the mempool and spend the nullifier before the member's own transaction records the receipt.

Functions​

FunctionWho may callChecks, in orderEffectsEventReverts
admit(bytes32 credentialId, uint256 identityCommitment)The credential's issuer (must still be registered) or the holder wallet bound in the holder registryNot paused; credential exists; caller is issuer or holder; isValid(credentialId); commitment not zero; group not fullCreates the group on first use (createGroup(address(this), 10 minutes), the gate is its admin), adds the memberMemberAdmitted(credentialId, groupId, commitment, actor)AccessGate__Paused, AccessGate__UnknownCredential, AccessGate__NotHolderOrIssuer, AccessGate__CredentialNotValid, AccessGate__ZeroCommitment, AccessGate__GroupFull
removeMember(bytes32 credentialId, uint256 identityCommitment, uint256[] siblings)Issuer (registered or not) or holderCredential exists; caller is issuer or holder; group existsRemoves the member (Semaphore checks the Merkle siblings)MemberRemoved(credentialId, groupId, commitment, actor)AccessGate__UnknownCredential, AccessGate__NotHolderOrIssuer, AccessGate__NoGroup, Semaphore errors
proveAccess(bytes32 credentialId, uint256 epoch, SemaphoreProof proof)The admitted party, from the address bound in the proofNot paused; proof checks (below); nullifier not spent; semaphore.verifyProofMarks the nullifier spentAccessProven(credentialId, epoch, nullifier, caller)AccessGate__Paused, proof check errors, AccessGate__NullifierSpent, AccessGate__InvalidProof
verifyAccess(bytes32 credentialId, uint256 epoch, address caller, SemaphoreProof proof) boolAnyone (view)Proof checks for callerNone; does not look at spent nullifiers and works while pausedProof check errors
accessGroupOf(bytes32 credentialId)Anyone (view)Credential exists, group existsReturns (groupId, size, slotsUsed)AccessGate__UnknownCredential, AccessGate__NoGroup
currentEpoch() uint256Anyone (view)block.timestamp / 1 days
scopeOf(bytes32 credentialId, uint256 epoch) uint256Anyone (view)The scope value a proof must carry

Proof checks (_checkProof)​

CheckError
The credential exists and isValid is trueAccessGate__UnknownCredential, AccessGate__CredentialNotValid
The credential has a groupAccessGate__NoGroup
epoch is the current epoch, the one before or the one afterAccessGate__EpochOutOfWindow(epoch, current)
proof.merkleTreeDepth <= 6AccessGate__DepthNotServed(depth)
proof.scope == scopeOf(credentialId, epoch)AccessGate__WrongScope(expected, actual)
proof.message == uint160(caller)AccessGate__WrongMessage(expected, actual)

What a proof is bound to​

function scopeOf(bytes32 credentialId, uint256 epoch) public view returns (uint256) {
return uint256(keccak256(abi.encode(SCOPE_DOMAIN, block.chainid, address(this), credentialId, epoch)));
}
BindingHowEffect
This credentialcredentialId in the scopeA proof for one credential is useless for another
This gate and chainaddress(this) and block.chainid in the scopeNo replay on another deployment or network
One dayepoch in the scope, accepted within plus or minus one epochEach member gets one receipt per credential per epoch, because Semaphore's nullifier is derived from the identity and the scope
The submitting addressmessage equals the caller addressA copied proof cannot be submitted from another address

The group root a proof uses must be the current root or one that changed less than 10 minutes ago (GROUP_ROOT_VALIDITY), which lets a party prove while a member is being added or removed elsewhere.

Limits​

  • 64 members per credential, for its whole life. The limit counts every commitment ever admitted, because Semaphore keeps the slot of a removed member in the tree. Removing a member frees nothing. 64 leaves keep the tree at depth 6 or less, which is what the gate serves and what the browser prover handles.
  • Admission needs a credential in good standing. A revoked, suspended, superseded or expired credential, or one whose issuer is suspended, cannot admit new members and cannot be proven against. Removal still works, also while paused or after revocation.
  • Issuer admission needs a registered issuer. A removed issuer may still remove members but cannot add new ones. The holder check uses holderOf(anchor.issuer, anchor.subjectCommitment), so it follows the binding made under the key that issued the credential.

Flow​

The browser side (the /access page, the Web Worker prover, the hash-checked circuit files) is in Zero-knowledge access.

Invariants the tests enforce​

  • Only the holder or the issuer can admit; strangers, revoked, suspended and paused credentials are refused; the 64th member is accepted and the 65th refused; removed members still count.
  • Proofs are refused for the wrong epoch window, scope, chain, message or tree depth, for tampered curve points and for removed members; a spent nullifier cannot be spent again; verifyAccess keeps answering true after the nullifier is spent.
  • The scope vector in blockchain/test/zk/fixtures/access-scope-vector.json is reproduced by both Solidity and TypeScript.
  • The gate fits the 20,000 byte budget for v0.3 and ZK contracts.

How the app uses it​

  • src/services/domain/access-service.ts admits and removes members and proves access; src/services/credentials/access-gateway.ts decides whether a gate exists for the set that holds a credential (the panels are shown only where it does).
  • Manage access on the holder list and the issuer dashboard opens the admit and remove panel. Group membership is rebuilt from MemberAdmitted and MemberRemoved events (src/lib/zk/group-events.ts), because removal needs the Merkle siblings.
  • The requester page /access proves access in the browser.
  • npm run access in blockchain/ is a command-line prover and submitter (blockchain/scripts/access.ts).

Gas​

From blockchain/gas-snapshot.json, measured with a group of three members: first admission (creates the group) 279,791; later admission 201,669; proveAccess 330,178; removeMember 138,405.

Source files​

  • blockchain/contracts/zk/CertichainAccessGate.sol
  • blockchain/contracts/zk/interfaces/ICertichainAccessGate.sol
  • blockchain/contracts/zk/SemaphoreImports.sol
  • blockchain/scripts/lib/deploy-access-gate.ts, blockchain/scripts/deploy-access-gate.ts, blockchain/scripts/verify-access-gate.ts
  • src/contracts/zk/deployment.11155111.json, src/services/domain/access-service.ts, src/lib/zk/

Tests​

  • blockchain/test/zk/AccessGate.test.ts, AccessGateProofs.test.ts, AccessJourney.test.ts, AccessCli.test.ts, AccessBrowserProver.test.ts, DeployAccessGate.test.ts, VerifyAccessTargets.test.ts

All are described in Zero-knowledge tests.