ShieldedAccessControl
This module provides a privacy-preserving role-based access control mechanism. Unlike AccessControl, which stores role assignments in a public map, ShieldedAccessControl stores roles as Merkle tree commitments which hides who holds a role while still allowing on-chain verification of authorization.
If your contract does not require privacy for role holders, the simpler AccessControl module is recommended.
Why Shielded Access Control?
In many applications, revealing which accounts hold administrative or privileged roles creates a security and privacy risk. ShieldedAccessControl addresses this by ensuring that role holders are never identified on-chain. Instead, callers prove ownership of a role using zero-knowledge proofs against Merkle tree commitments.
This is useful in scenarios such as:
- Private governance: voters or committee members prove eligibility without revealing identity.
- Confidential operations: admins can manage a system without their addresses being publicly linked to the contract.
- Pseudonymous authorization: role holders maintain a stable pseudonymous identity for the lifetime of their role, but that identity cannot be linked to a real-world identity without key compromise.
How It Works
ShieldedAccessControl is built on three cryptographic primitives:
Role commitments are Merkle tree leaves that bind a role to an account without revealing either. When a role is granted, a commitment is inserted into a Merkle tree:
roleCommitment = SHA256(role || accountId || instanceSalt || commitmentDomain)Account identifiers are privacy-preserving identity commitments derived from a secret key and a per-deployment instance salt. A single secret key can be used across multiple roles within the same contract:
accountId = SHA256(secretKey || instanceSalt || accountIdDomain)Role nullifiers are one-time burn tokens. When a role is revoked, its nullifier is inserted into a set, permanently invalidating the corresponding commitment:
roleNullifier = SHA256(roleCommitment || nullifierDomain)The instanceSalt is a per-deployment random value that prevents commitment collisions across contracts
and ensures that the same secret key produces different identities in different deployments.
Public ledger and private witnesses
What is on the ledger:
- _operatorRoles: the Merkle tree of role commitments. Inserts expose only the internal leaf hash, not the commitment value.
- _grantedRoleNullifiers: the nullifier of every
(role, accountId)pairing ever granted. - _roleCommitmentNullifiers: the nullifier of every pairing that has been revoked.
_adminRoles, the sealed_instanceSalt, and_isInitialized.
What each role holder keeps private, and supplies through witnesses at proving time:
- The
secretKey, via wit_secretKey. It is hashed with_instanceSaltinside the proof and never reaches the ledger. - The Merkle path to their role commitment, via wit_getRoleCommitmentPath.
The consumer's witness implementation builds this path from the exported
_operatorRolesstate.
Witness implementations are authored by the consuming DApp. The TypeScript witnesses in the repository are test-only and are not published with the package.
Using ShieldedAccessControl
Usage follows the same pattern as AccessControl which is to define role identifiers, grant them during construction, and enforce them with assertOnlyRole. The key difference is that role assignments are stored as Merkle tree commitments rather than in a public map, and revocation uses a nullifier scheme rather than simply flipping a boolean. Account identifiers also incorporate a per-instance salt, preventing cross-contract correlation of the same secret key.
pragma language_version >= 0.26.0;
import CompactStandardLibrary;
import "./node_modules/@openzeppelin/compact-contracts/access/ShieldedAccessControl"
prefix SAC_;
export sealed ledger MINTER_ROLE: Bytes<32>;
constructor(
instanceSalt: Bytes<32>,
defaultAdmin: SAC_AccountIdentifier,
minter: SAC_AccountIdentifier
) {
SAC_initialize(instanceSalt);
MINTER_ROLE = persistentHash<Bytes<32>>(pad(32, "MINTER_ROLE"));
// Grant the default admin role and the minter role
SAC__grantRole(SAC_DEFAULT_ADMIN_ROLE(), defaultAdmin);
SAC__grantRole(MINTER_ROLE as SAC_RoleIdentifier, minter);
}
export circuit mint(/* ... */): [] {
SAC_assertOnlyRole(MINTER_ROLE as SAC_RoleIdentifier);
// ... minting logic
}The instanceSalt must be nonzero and generated using a cryptographically secure random number generator
(e.g. crypto.getRandomValues()) prior to deployment. Weak or predictable salts weaken privacy guarantees.
Deriving Account Identifiers
Before an account can be granted a role, the grantor needs the grantee's AccountIdentifier.
This is derived off-chain using computeAccountId:
const accountId = SAC_computeAccountId(secretKey, instanceSalt);The secretKey is a 32-byte cryptographically secure random value that the user keeps private.
The instanceSalt is the same value used during contract initialization.
The secretKey must never be logged, persisted in plaintext, or exposed in browser devtools.
Loss of the key means loss of all roles held under that identity. Key exposure allows impersonation
and retroactive deanonymization.
Granting and Revoking Roles
Role management works similarly to AccessControl. Every role has an associated admin role. Only accounts that can prove they hold the admin role may call grantRole and revokeRole. By default, all roles are administered by DEFAULT_ADMIN_ROLE. Custom admin relationships can be set using _setRoleAdmin.
Each (role, accountId) pairing has exactly one lifecycle:
- Grant: the first grant inserts the role commitment into the Merkle tree and records the pairing's nullifier in _grantedRoleNullifiers. Granting a pairing that is already active is a no-op, so a pairing never consumes more than one tree slot.
- Revoke: revoking (or renouncing) inserts the nullifier into _roleCommitmentNullifiers.
A pairing that was never granted cannot be revoked; the call reverts with
role was never granted.
There is one important difference from unshielded AccessControl:
revocation is permanent for a given identity. When a role is revoked,
its nullifier is burned, and the same (role, accountId) pairing can never be granted again.
To be re-authorized, the user must generate a new secretKey to produce a new accountId.
Other roles held under the old secret key remain valid. The user will need to retain both keys until all roles under the old key are no longer needed.
Renouncing Roles
renounceRole allows a role holder to voluntarily give up their own role.
This is useful if a trusted device is compromised and the holder wants to immediately invalidate their credentials.
The caller must confirm their accountId to prevent accidental renunciation,
and the pairing must have been granted. Consumers with no need for self-revocation should not export this circuit.
Proving Role Ownership
Two circuits are available for verifying role ownership:
- assertOnlyRole: reverts if the caller cannot prove they hold the role. Use this to gate access to circuits.
- canProveRole: returns a boolean without reverting. Useful for conditional logic where lacking a role is not an error.
Both circuits disclose a nullifier, which means every call made under the same role instance can be linked by an outside observer. This provides pseudonymity, not anonymity.
Privacy Considerations
ShieldedAccessControl provides strong privacy guarantees, but it's important to understand what is and isn't hidden:
What observers CAN see:
- When roles are granted and revoked
- All role identifiers and which are admin identifiers (disclosed via getRoleAdmin, _setRoleAdmin, grantRole, and revokeRole)
- How many admins exist
- All role nullifiers (disclosed via
Setoperations in every grant, revoke, and role-proof transaction) - The same nullifier across calls, allowing them to link all actions performed under the same role instance over time (pseudonymity, not anonymity)
- Correlation between a grant and its revocation (the same nullifier appears in both transactions)
- Which role is being granted or revoked when the admin-checked circuits (grantRole, revokeRole) are used, since those disclose the role identifier in the same transaction
- The number of distinct
(role, accountId)pairings ever granted across all roles; per-role counts are also inferable when the admin-checked circuits are used
What observers CANNOT see:
- The identity of any role holder, so long as secret keys are kept private and generated using cryptographically secure random values
- The value of role commitments stored in the Merkle tree (MerkleTree inserts hash the value internally; only the internal leaf hash appears as a public input, unlike Set operations where values are directly visible)
Users can be retroactively deanonymized if their secret key is exposed.
The internal circuits (_grantRole, _revokeRole) do not disclose the role identifier, offering stronger privacy when wrapped with custom authorization logic. The trade-off is that the implementing contract takes full responsibility for access control.
Security Considerations
- The
_operatorRolesMerkle tree has a fixed capacity of 2^20 leaf slots. Each distinct(role, accountId)pairing consumes one slot, re-granting an active pairing is a no-op, and slots cannot be reclaimed after revocation. Once exhausted, the contract can never grant a new role. Monitor slot consumption off-chain (firstFree()on the exported tree in TypeScript,isFull()in a circuit) and plan capacity accordingly. - A single secret key compromise exposes all roles held by that user within the contract instance. Users requiring compartmentalization between roles should use separate keys per role. On compromise, coordinate with admins to revoke every role under the compromised
accountIdand re-grant under a new identity. - A pairing can only be revoked after it was granted, so the nullifier set holds at most one entry per granted pairing. Admin trust is still a fundamental assumption: an admin can revoke any granted pairing, and revocation is irreversible.
- The SHA256 hashing function is used throughout.
For the full API reference, see the ShieldedAccessControl API.