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 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.
| Contract | Source | Role |
|---|---|---|
Semaphore | @semaphore-protocol/contracts 4.14.3, imported through blockchain/contracts/zk/SemaphoreImports.sol | Groups, member trees, proof verification |
SemaphoreVerifier | Same package | The Groth16 verifier for tree depths 1 to 32 |
PoseidonT3 | poseidon-solidity, deployed at its published deterministic address | Hash 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):
| Contract | Address |
|---|---|
CertichainAccessGate | 0xFd75Ee64dA6127BA4D61799920291E5016518af6 |
Semaphore | 0xF5B3531820E0fc5d77DEE52cbf3BBF82B7c14A8E |
SemaphoreVerifier | 0xF562A9A42B6cDA79fd2d1f13699486251473b001 |
PoseidonT3 | 0x3333333C0A88F9BE4fd23ed0536F9B6c427e3B93 |
| Credential registry served | 0x53Fb56822E507bAf5c0221D993108495CE1931ea (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
| Mapping | Meaning |
|---|---|
_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
| Function | Who may call | Checks, in order | Effects | Event | Reverts |
|---|---|---|---|---|---|
admit(bytes32 credentialId, uint256 identityCommitment) | The credential's issuer (must still be registered) or the holder wallet bound in the holder registry | Not paused; credential exists; caller is issuer or holder; isValid(credentialId); commitment not zero; group not full | Creates the group on first use (createGroup(address(this), 10 minutes), the gate is its admin), adds the member | MemberAdmitted(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 holder | Credential exists; caller is issuer or holder; group exists | Removes 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 proof | Not paused; proof checks (below); nullifier not spent; semaphore.verifyProof | Marks the nullifier spent | AccessProven(credentialId, epoch, nullifier, caller) | AccessGate__Paused, proof check errors, AccessGate__NullifierSpent, AccessGate__InvalidProof |
verifyAccess(bytes32 credentialId, uint256 epoch, address caller, SemaphoreProof proof) bool | Anyone (view) | Proof checks for caller | None; does not look at spent nullifiers and works while paused | Proof check errors | |
accessGroupOf(bytes32 credentialId) | Anyone (view) | Credential exists, group exists | Returns (groupId, size, slotsUsed) | AccessGate__UnknownCredential, AccessGate__NoGroup | |
currentEpoch() uint256 | Anyone (view) | block.timestamp / 1 days | |||
scopeOf(bytes32 credentialId, uint256 epoch) uint256 | Anyone (view) | The scope value a proof must carry |
Proof checks (_checkProof)
| Check | Error |
|---|---|
The credential exists and isValid is true | AccessGate__UnknownCredential, AccessGate__CredentialNotValid |
| The credential has a group | AccessGate__NoGroup |
epoch is the current epoch, the one before or the one after | AccessGate__EpochOutOfWindow(epoch, current) |
proof.merkleTreeDepth <= 6 | AccessGate__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)));
}
| Binding | How | Effect |
|---|---|---|
| This credential | credentialId in the scope | A proof for one credential is useless for another |
| This gate and chain | address(this) and block.chainid in the scope | No replay on another deployment or network |
| One day | epoch in the scope, accepted within plus or minus one epoch | Each member gets one receipt per credential per epoch, because Semaphore's nullifier is derived from the identity and the scope |
| The submitting address | message equals the caller address | A 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;
verifyAccesskeeps answering true after the nullifier is spent. - The scope vector in
blockchain/test/zk/fixtures/access-scope-vector.jsonis 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.tsadmits and removes members and proves access;src/services/credentials/access-gateway.tsdecides 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
MemberAdmittedandMemberRemovedevents (src/lib/zk/group-events.ts), because removal needs the Merkle siblings. - The requester page
/accessproves access in the browser. npm run accessinblockchain/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.solblockchain/contracts/zk/interfaces/ICertichainAccessGate.solblockchain/contracts/zk/SemaphoreImports.solblockchain/scripts/lib/deploy-access-gate.ts,blockchain/scripts/deploy-access-gate.ts,blockchain/scripts/verify-access-gate.tssrc/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.