Conversation
|
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:
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 |
|
@emnul good idea to highlight these approaches
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:
Do you agree?
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) |
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.
Yup, not my favorite approach. Super error prone and overly complex. Using the MT in an "append only" way makes it much simpler
From what I understand, if we use the commitment generation scheme described then a |
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? |
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.
I plan on moving forward this scheme |
|
We need to create a |
|
@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 |
|
@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 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 :) |
This can work by casting Uint -> Field -> Bytes<32> |
andrew-fleming
left a comment
There was a problem hiding this comment.
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
contracts/shieldedAccessControl/src/ShieldedAccessControl.compact
Outdated
Show resolved
Hide resolved
contracts/shieldedAccessControl/src/ShieldedAccessControl.compact
Outdated
Show resolved
Hide resolved
contracts/shieldedAccessControl/src/ShieldedAccessControl.compact
Outdated
Show resolved
Hide resolved
contracts/shieldedAccessControl/src/ShieldedAccessControl.compact
Outdated
Show resolved
Hide resolved
contracts/shieldedAccessControl/src/ShieldedAccessControl.compact
Outdated
Show resolved
Hide resolved
contracts/shieldedAccessControl/src/ShieldedAccessControl.compact
Outdated
Show resolved
Hide resolved
contracts/shieldedAccessControl/src/ShieldedAccessControl.compact
Outdated
Show resolved
Hide resolved
contracts/shieldedAccessControl/src/ShieldedAccessControl.compact
Outdated
Show resolved
Hide resolved
contracts/shieldedAccessControl/src/witnesses/ShieldedAccessControlWitnesses.ts
Outdated
Show resolved
Hide resolved
contracts/shieldedAccessControl/src/witnesses/ShieldedAccessControlWitnesses.ts
Outdated
Show resolved
Hide resolved
…nd _computeRoleCommitment circuits
contracts/README.md
Outdated
There was a problem hiding this comment.
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
There was a problem hiding this comment.
agree on this. This PR is taking longer than anticipated, and if we can split additional elements into their independent flows
| */ | ||
| circuit _computeAccountId(role: RoleIdentifier): AccountIdentifier { | ||
| return persistentHash<Vector<4, Bytes<32>>>( | ||
| [ownPublicKey().bytes, | ||
| wit_secretNonce(role), | ||
| _instanceSalt, | ||
| pad(32, "ShieldedAccessControl:accountId")] | ||
| ) | ||
| as AccountIdentifier; |
There was a problem hiding this comment.
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;
}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>
…elin/midnight-contracts into shielded-access-control
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
contracts/src/access/witnesses/ShieldedAccessControlWitnesses.ts (1)
67-73: Validate role/nonce length at state helper boundaries.
withRoleAndNonceandsetRoleshould enforce 32-byteroleandnonceinputs 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
📒 Files selected for processing (24)
CHANGELOG.mdcontracts/src/access/ShieldedAccessControl.compactcontracts/src/access/test/ShieldedAccessControl.test.tscontracts/src/access/test/mocks/MockAccessControl.compactcontracts/src/access/test/mocks/MockOwnable.compactcontracts/src/access/test/mocks/MockShieldedAccessControl.compactcontracts/src/access/test/mocks/MockZOwnablePK.compactcontracts/src/access/test/simulators/ShieldedAccessControlSimulator.tscontracts/src/access/test/simulators/ZOwnablePKSimulator.tscontracts/src/access/witnesses/ShieldedAccessControlWitnesses.tscontracts/src/access/witnesses/ZOwnablePKWitnesses.tscontracts/src/archive/test/mocks/MockShieldedToken.compactcontracts/src/security/test/mocks/MockInitializable.compactcontracts/src/security/test/mocks/MockPausable.compactcontracts/src/token/test/mocks/MockFungibleToken.compactcontracts/src/token/test/mocks/MockMultiToken.compactcontracts/src/token/test/mocks/MockNonFungibleToken.compactcontracts/src/utils/test/mocks/MockUtils.compactpackages/simulator/README.mdpackages/simulator/test/fixtures/sample-contracts/witnesses/SampleZOwnableWitnesses.tspackages/simulator/test/fixtures/sample-contracts/witnesses/WitnessWitnesses.tspackages/simulator/test/fixtures/utils/address.tspackages/simulator/test/integration/SampleZOwnableSimulator.tspackages/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
Types of changes
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:
accountIdis SHA256(zcpk, nonce, instanceSalt, accountDomain)roleis a uniqueBytes<32>identifierinstanceSaltis an immutable, cryptographically strong random value provided on deploymentcommitmentDomainis 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
Notes on Contract Design
nullifier = H(roleId, accountId, instanceSalt, nullifierDomain). With this in mind, we can save on circuit size by deriving nullifiers asnullifier = 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
_grantRolecircuit 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
_grantRoleand_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
initializecircuit assert that theinstanaceSaltis not 0?_computeXXXcircuits be exported? Exporting these circuits increases the risk that a module consumer would build flows that leak this metadata and increase linkabilityFuture Improvements
_epochledger variable and a rotation circuitexport circuit rotateEpoch(): []restricted to admin or governanceSummary by CodeRabbit
Release Notes
New Features
Tests
Documentation
Chores