Skip to content

Shielded AccessControl#190

Open
emnul wants to merge 363 commits intomainfrom
shielded-access-control
Open

Shielded AccessControl#190
emnul wants to merge 363 commits intomainfrom
shielded-access-control

Conversation

@emnul
Copy link
Copy Markdown
Contributor

@emnul emnul commented Jul 23, 2025

Types of changes

  • New feature (non-breaking change which adds functionality)

Closes #88

Summary

This pull request introduces a Shielded AccessControl module. Roles are stored as MerkleTree commitments to avoid disclosing information regarding the roles an account may have. Role commitments are created with using the following hashing scheme SHA256 ( role | accountId | instanceSalt | commitmentDomain ) where:

  • accountId is SHA256(zcpk, nonce, instanceSalt, accountDomain)
  • role is a unique Bytes<32> identifier
  • instanceSalt is an immutable, cryptographically strong random value provided on deployment
  • commitmentDomain is the following zero padded 32 byte string: "ShieldedAccessControl:commitment"

In this RBAC model, role commitments behave like private bearer tokens. Possession of a valid, non-revoked role commitment grants authorization. Revocation permanently burns the role instance, requiring explicit new issuance under a new account identifier. Users must rotate their identity (new nonce) to be re-authorized. This creates stronger security invariants over traditional RBAC systems and enables privacy-preserving identity rotation.

Privacy Assumptions

  • Outside observers will know when an admin is added and how many admins exist.
  • Outside observers will know which role identifiers are admin identifiers.
  • Outside observers will have knowledge of all role identifiers.
  • Outside observers will have knowledge of role additions and revocations.
  • Outside observers will NOT be able to identify the public address of any role holder so long as secret nonce values are kept private and generated using cryptographically secure random values.

Notes on Contract Design

  • One of the goals of this Shielded Access Control implementation is to keep the API consistent with the Unshielded Access Control module.
  • Compact does not have a ZK friendly hashing function that can be used to derive state at this time, so the SHA256 hash function must be used. The usage of SHA256 incurs a large circuit size penalty on the contract which in turn slows compilation times.
  • role commitments are tightly coupled to nullifiers. If an observer has knowledge of a role commitment they can immediately also compute its nullifier. All inputs that make up role commitments must be made public in this design. Thus, we cannot independently namespace a nullifier eg nullifier = H(roleId, accountId, instanceSalt, nullifierDomain). With this in mind, we can save on circuit size by deriving nullifiers as nullifier = H(roleCommitment, nullifierDomain). Future designs should re-evaluate this tight coupling if inputs become more private.

Why use an instance salt instead of a contract address for instance separation in the role commitment scheme?

The a contract address can only referenced after the contract is successfully deployed. Thus, calling the _grantRole circuit in a contract constructor will fail requiring a two step initialization process that is error prone and unsafe. Using an instance salt value avoids failed deployments due to this unique condition and allows roles to be completely configured before the contract is live in a production environment.

Why is the Merkle tree append only?

Modifying or deleting role commitments from the Merkle tree may invalidate legitimate roles that exist along the path from a leaf to the root breaking functionality. Furthermore, this behavior leaks information allowing an observer to determine the some data was removed, approximate location or count of removed entries, and correlate changes with user actions or events. In order to minimize information leakage and avoid a complex Merkle tree storage management solution that would inevitably conflict with the goals of this module (maintaining the privacy of user roles), an append only solution was chosen.

Why re-compute commitments and nullifiers in _grantRole and _revokeRole?

Recomputing these values minimizes the scope of what must be exposed publicly. While it comes at a significant performance cost, it maximizes the allowable privacy of the module.

Open Questions

  • Should the initialize circuit assert that the instanaceSalt is not 0?
  • Is 2^20 large enough for the ledger MerkleTrees? Should it be larger?
  • Should _computeXXX circuits be exported? Exporting these circuits increases the risk that a module consumer would build flows that leak this metadata and increase linkability

Future Improvements

  • Unlinkability over time can be improved using an _epoch ledger variable and a rotation circuit export circuit rotateEpoch(): [] restricted to admin or governance
  • Unlinkability can be improved using an Invite/claim flow: admin publishes an “issuance note” commitment; user later claims it privately by proving knowledge of the note secret, then the contract updates _operatorRoles.
    • This prevents observers from linking admin action → recipient identity commitment.
  • Use ZK-friendly hashes when a stable hashing algorithm is available in the language

Summary by CodeRabbit

Release Notes

  • New Features

    • Added ShieldedAccessControl system providing privacy-preserving role-based access management with cryptographic commitments and revocation tracking.
  • Tests

    • Added comprehensive test suites and mock implementations for access control modules.
  • Documentation

    • Updated simulator documentation with generic ledger type examples.
  • Chores

    • Added production safety warnings to test mock contracts.
    • Enhanced witness interfaces with improved generic ledger type support.

@emnul emnul changed the title Init Shielded AcessControl Shielded AccessControl Jul 24, 2025
@emnul
Copy link
Copy Markdown
Contributor Author

emnul commented Jul 24, 2025

I'm not sure how to approach role invalidation. There's no easy way to clear role commitments from the MT. Here are some ideas I had:

  1. Use a Map<Bytes<32>, Uint<64>> ledger state variable to track where a roleCommitment is in the MT. Use a Counter ledger state variable to track current MT index . Use InsertDefault() MT to clear roleCommitment from the MT on revokeRole.
  2. Use a Nullifier set to invalidate roleCommitments. On revokeRole, add roleCommitment to Nullifier set. hasRole will check if roleCommitment is both in the MT and not in nullifier set. grantRole will remove roleCommitment from nullifier set if already in MT.

I would like your thoughts, I keep going back and forth between these two designs. In my opinion, option 2 feels like a more straightforward solution without adding too much complexity

@andrew-fleming
Copy link
Copy Markdown
Contributor

@emnul good idea to highlight these approaches

Use a Map<Bytes<32>, Uint<64>> ledger state variable to track where a roleCommitment is in the MT. Use a Counter ledger state variable to track current MT index . Use InsertDefault() MT to clear roleCommitment from the MT on revokeRole.

When you say to clear the commitment from the MT, you mean to literally change (delete) the leaf, right? I think this will be tricky bc we'd have to update the root whenever we revoke roles and this will break verification for other commitments that rely on the root being the same. I imagine we'd need a mechanism that logs previous roots for previous commitments (and/or use an epoch scheme)—compact has HistoricMerkleTree to check against previous roots, but then we run into the issue of false positives as mentioned in the docs:

The distinction between MerkleTree<n, T> and HistoricMerkleTree<n, T> is that checkRoot for the latter accepts proofs made against prior versions of the Merkle tree. This is helpful if a tree has frequent insertions, as these otherwise invalidate old proofs, although HistoricMerkleTree is not suitable if items are frequently removed or replaced, as this could lead to proofs being considered valid which should not be.

Do you agree?

Use a Nullifier set to invalidate roleCommitments. On revokeRole, add roleCommitment to Nullifier set. hasRole will check if roleCommitment is both in the MT and not in nullifier set. grantRole will remove roleCommitment from nullifier set if already in MT.

If we remove the commitment from the nullifier set, won't this link the new roleCommitment with the removed nullifier? We can maybe use something like a nonce (or timestamp when available) instead to avoid deleting the nullifier (if you agree)

@emnul
Copy link
Copy Markdown
Contributor Author

emnul commented Jul 25, 2025

When you say to clear the commitment from the MT, you mean to literally change (delete) the leaf, right? I think this will be tricky bc we'd have to update the root whenever we revoke roles and this will break verification for other commitments that rely on the root being the same.

Yeah pretty much just zeroing out the leaf. I also thought we might run into trouble with the root changing from my general knowledge on how MTs work, but wasn't sure since I haven't played around with the data structure to examine how it works. In practice, I'm sure it changes the root like you mentioned.

I imagine we'd need a mechanism that logs previous roots for previous commitments (and/or use an epoch scheme)—compact has HistoricMerkleTree to check against previous roots, but then we run into the issue of false positives as mentioned in the docs:

Yup, not my favorite approach. Super error prone and overly complex. Using the MT in an "append only" way makes it much simpler

If we remove the commitment from the nullifier set, won't this link the new roleCommitment with the removed nullifier? We can maybe use something like a nonce (or timestamp when available) instead to avoid deleting the nullifier (if you agree)

From what I understand, if we use the commitment generation scheme described then a roleCommitment should be deterministic. Thus, we'd never need to add to the MT more than once for a role unless the nonce is lost and there would be no new roleCommitment for the same role. Definitely check my logic here tho. Could be missing something

@andrew-fleming
Copy link
Copy Markdown
Contributor

If we remove the commitment from the nullifier set, won't this link the new roleCommitment with the removed nullifier? We can maybe use something like a nonce (or timestamp when available) instead to avoid deleting the nullifier (if you agree)

From what I understand, if we use the commitment generation scheme described then a roleCommitment should be deterministic. Thus, we'd never need to add to the MT more than once for a role unless the nonce is lost and there would be no new roleCommitment for the same role. Definitely check my logic here tho. Could be missing something

Ahh forgive me, we're not adding a new commitment in the mentioned scheme. Yes, you're correct, but this still provides a link between the removed nullifier and the existing commitments. Not exactly home address + phone number-level data, but data nonetheless

IMO if a user is granted and revoked the same role 3 times, there should be three different commitments and nullifiers. This is where salt should be included in the nonce derivation...or something else to create three different commitments with the same grantRole args. Otherwise, this becomes the user's responsibility to change their SK if they're granted, revoked, and re-granted a role to maintain privacy. It'd be better and safer for the user if this was handled on the smart contract level

Thoughts?

@emnul
Copy link
Copy Markdown
Contributor Author

emnul commented Jul 29, 2025

Yes, you're correct, but this still provides a link between the removed nullifier and the existing commitments. Not exactly home address + phone number-level data, but data nonetheless

That makes sense. My thinking was we could save on storage by re-using commitments, but I see now this comes with some increased risk of leaking data. We should definitely prioritize shielding data vs optimizing circuits / storage.

IMO if a user is granted and revoked the same role 3 times, there should be three different commitments and nullifiers.

I plan on moving forward this scheme

@emnul
Copy link
Copy Markdown
Contributor Author

emnul commented Jul 29, 2025

We need to create aVector<3, Bytes<32>> in order to hash the nonce, roleId and public key, but we cannot cast the Bytes<16> to a Bytes<32> value in Compact at this time, so I'm proposing we change the nonce circuit param type to Bytes<32>

@emnul emnul self-assigned this Jul 30, 2025
@emnul emnul moved this from Backlog to In progress in OZ Development for Midnight Jul 30, 2025
@emnul emnul added this to the 1. current milestone Jul 30, 2025
@emnul emnul added the enhancement New feature or request label Jul 30, 2025
@emnul
Copy link
Copy Markdown
Contributor Author

emnul commented Jul 30, 2025

@andrew-fleming what do we think about introducing a Counter ledger variable and a Mapping from a roleCommitment Bytes<32> to a Counter value Uint<64>?

The idea here is to use the Counter to satisfy the uniqueness property we want for each roleCommitment. The system can be more trustless in that way. It does expose some data that an attacker could leverage to reveal a commitment, but I think the nonce implementation mitigates this risk.

Alternatively, we could inject a salt value into the circuit generated via a witness function off-chain, but again there's nothing forcing an admin to use a cryptographically secure random number generator and then we also run into a problem where only the admin would know this salt value which breaks the implementation assumptions that user has all information to generate their roleCommitment to pass any assertOnlyRole checks.

@andrew-fleming
Copy link
Copy Markdown
Contributor

@emnul I think this could work. Another idea in a similar vein would be to use the MT index (assuming this is append-only) and just have a mapping for commitment => index

Whatever design we end up using, consider too that there's probably room for different flavors—each with their own tradeoffs. If there's enough flexibility with just one then that works too :)

@emnul
Copy link
Copy Markdown
Contributor Author

emnul commented Jul 31, 2025

I think this could work. Another idea in a similar vein would be to use the MT index (assuming this is append-only) and just have a mapping for commitment => index

Whatever design we end up using, consider too that there's probably room for different flavors—each with their own tradeoffs. If there's enough flexibility with just one then that works too :)

Ahhh this cant work because we would need to convert the Uint<64> value to a Bytes<32> value to hash everything together but this isn't possible in compact at this time 🥲

This can work by casting Uint -> Field -> Bytes<32>

Copy link
Copy Markdown
Contributor

@andrew-fleming andrew-fleming left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good start @emnul! I left some comments.

I'm also wondering if there's a more modular approach that we can use where HKDF and the like can be used as extensions instead of direct recommendations. We can always change the module later, so I suppose it's okay for it to be opinionated now. I'll give a deeper review when there are tests as I believe there are some issues we need to take care of that would have been caught already

Another small note: sending the pk and nonce to the admin IMO creates a shift in expectations regarding how this model works. The idea is that there's user intent to have a role, right? There's nothing to stop an admin from logging the pk and nonce for everyone. When roles are revoked from users, an admin can replay those values. I imagine this could be an issue with some protocols. The model follows a user-intent design, but that assumption doesn't hold if an admin can replay. Adding a timestamp to the nonce hash could prevent this

@emnul emnul requested review from 0xisk and andrew-fleming March 18, 2026 01:44
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd propose adding the contracts/README into another PR. It's better organizational discipline since it's not directly related to the feature and it's worth its own discussion to not muddy up this already noisy PR

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree on this. This PR is taking longer than anticipated, and if we can split additional elements into their independent flows

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved this change to #403

Copy link
Copy Markdown
Member

@0xisk 0xisk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good @emnul left some comments.

Comment on lines +785 to +793
*/
circuit _computeAccountId(role: RoleIdentifier): AccountIdentifier {
return persistentHash<Vector<4, Bytes<32>>>(
[ownPublicKey().bytes,
wit_secretNonce(role),
_instanceSalt,
pad(32, "ShieldedAccessControl:accountId")]
)
as AccountIdentifier;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, or simply like this:

  circuit _computeAccountId(role: RoleIdentifier): AccountIdentifier {
    return computeAccountId(ownPublicKey(), wit_secretNonce(role), _instanceSalt);
  }

  export pure circuit computeAccountId(account: ZswapCoinPublicKey, secretNonce: Bytes<32>, instanceSalt: Bytes<32>): AccountIdentifier {
    return persistentHash<Vector<4, Bytes<32>>>(
             [account.bytes,
              secretNonce,
              instanceSalt,
              pad(32, "ShieldedAccessControl:accountId")]
             )
           as AccountIdentifier;
  }

emnul and others added 9 commits March 18, 2026 13:12
Co-authored-by: 0xisk <iskander.andrews@openzeppelin.com>
Signed-off-by: ⟣ €₥ℵ∪ℓ ⟢ <34749913+emnul@users.noreply.github.com>
Co-authored-by: 0xisk <iskander.andrews@openzeppelin.com>
Signed-off-by: ⟣ €₥ℵ∪ℓ ⟢ <34749913+emnul@users.noreply.github.com>
Co-authored-by: 0xisk <iskander.andrews@openzeppelin.com>
Signed-off-by: ⟣ €₥ℵ∪ℓ ⟢ <34749913+emnul@users.noreply.github.com>
Co-authored-by: 0xisk <iskander.andrews@openzeppelin.com>
Signed-off-by: ⟣ €₥ℵ∪ℓ ⟢ <34749913+emnul@users.noreply.github.com>
@emnul emnul requested review from 0xisk and andrew-fleming March 18, 2026 18:22
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts (1)

67-73: Validate role/nonce length at state helper boundaries.

withRoleAndNonce and setRole should enforce 32-byte role and nonce inputs to fail early with clear errors.

Suggested patch
   withRoleAndNonce: (
     role: Buffer,
     nonce: Buffer,
   ): ShieldedAccessControlPrivateState => {
+    if (role.length !== 32) {
+      throw new Error(
+        `withRoleAndNonce: expected 32-byte role, received ${role.length} bytes`,
+      );
+    }
+    if (nonce.length !== 32) {
+      throw new Error(
+        `withRoleAndNonce: expected 32-byte nonce, received ${nonce.length} bytes`,
+      );
+    }
     const roleString = role.toString('hex');
-    return { roles: { [roleString]: nonce } };
+    return { roles: { [roleString]: new Uint8Array(nonce) } };
   },

   setRole: (
     privateState: ShieldedAccessControlPrivateState,
     role: Buffer,
     nonce: Buffer,
   ): ShieldedAccessControlPrivateState => {
+    if (role.length !== 32) {
+      throw new Error(
+        `setRole: expected 32-byte role, received ${role.length} bytes`,
+      );
+    }
+    if (nonce.length !== 32) {
+      throw new Error(
+        `setRole: expected 32-byte nonce, received ${nonce.length} bytes`,
+      );
+    }
     const roleString = role.toString('hex');
     const roles: Record<string, Uint8Array> = {};

Also applies to: 75-92

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts` around
lines 67 - 73, Add explicit 32-byte length checks to the helper boundaries so
invalid buffers fail early: in withRoleAndNonce(role: Buffer, nonce: Buffer)
verify role.length === 32 and nonce.length === 32 and throw a clear Error like
"role must be 32 bytes" / "nonce must be 32 bytes" if not; likewise add the same
validation to the setRole method (and any analogous setters in
ShieldedAccessControlPrivateState) to enforce inputs are 32-byte Buffers before
using toString('hex') or storing in roles.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/src/access/test/simulators/ShieldedAccessControlSimulator.ts`:
- Around line 160-167: The error thrown in getCurrentSecretNonce uses the
undefined variable roleNonce in its message; update the throw to reference the
hex-encoded key roleString (the lookup key into getPrivateState().roles) so the
message shows the missing role id, e.g. throw new Error(`Missing secret nonce
for role ${roleString}`), and keep the rest of the function (roleString,
roleNonce, and the getPrivateState().roles lookup) unchanged.

---

Nitpick comments:
In `@contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts`:
- Around line 67-73: Add explicit 32-byte length checks to the helper boundaries
so invalid buffers fail early: in withRoleAndNonce(role: Buffer, nonce: Buffer)
verify role.length === 32 and nonce.length === 32 and throw a clear Error like
"role must be 32 bytes" / "nonce must be 32 bytes" if not; likewise add the same
validation to the setRole method (and any analogous setters in
ShieldedAccessControlPrivateState) to enforce inputs are 32-byte Buffers before
using toString('hex') or storing in roles.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 577168fb-b09b-49cf-af4b-1177aaa05bcc

📥 Commits

Reviewing files that changed from the base of the PR and between 4757096 and 28cc91e.

📒 Files selected for processing (24)
  • CHANGELOG.md
  • contracts/src/access/ShieldedAccessControl.compact
  • contracts/src/access/test/ShieldedAccessControl.test.ts
  • contracts/src/access/test/mocks/MockAccessControl.compact
  • contracts/src/access/test/mocks/MockOwnable.compact
  • contracts/src/access/test/mocks/MockShieldedAccessControl.compact
  • contracts/src/access/test/mocks/MockZOwnablePK.compact
  • contracts/src/access/test/simulators/ShieldedAccessControlSimulator.ts
  • contracts/src/access/test/simulators/ZOwnablePKSimulator.ts
  • contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts
  • contracts/src/access/witnesses/ZOwnablePKWitnesses.ts
  • contracts/src/archive/test/mocks/MockShieldedToken.compact
  • contracts/src/security/test/mocks/MockInitializable.compact
  • contracts/src/security/test/mocks/MockPausable.compact
  • contracts/src/token/test/mocks/MockFungibleToken.compact
  • contracts/src/token/test/mocks/MockMultiToken.compact
  • contracts/src/token/test/mocks/MockNonFungibleToken.compact
  • contracts/src/utils/test/mocks/MockUtils.compact
  • packages/simulator/README.md
  • packages/simulator/test/fixtures/sample-contracts/witnesses/SampleZOwnableWitnesses.ts
  • packages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.ts
  • packages/simulator/test/fixtures/utils/address.ts
  • packages/simulator/test/integration/SampleZOwnableSimulator.ts
  • packages/simulator/test/integration/WitnessSimulator.ts
✅ Files skipped from review due to trivial changes (7)
  • contracts/src/token/test/mocks/MockFungibleToken.compact
  • CHANGELOG.md
  • contracts/src/token/test/mocks/MockMultiToken.compact
  • contracts/src/access/test/mocks/MockZOwnablePK.compact
  • contracts/src/security/test/mocks/MockPausable.compact
  • contracts/src/archive/test/mocks/MockShieldedToken.compact
  • contracts/src/access/test/mocks/MockOwnable.compact
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/simulator/test/fixtures/utils/address.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

Add shielded access control

4 participants