diff --git a/contracts/src/access/AccessControl.compact b/contracts/src/access/AccessControl.compact index 021d2e43..9df41119 100644 --- a/contracts/src/access/AccessControl.compact +++ b/contracts/src/access/AccessControl.compact @@ -9,13 +9,24 @@ pragma language_version >= 0.21.0; * This module provides a role-based access control mechanism, where roles can be used to * represent a set of permissions. * + * Authorization is based on a witness-derived identity scheme. Each caller proves knowledge + * of a secret key by injecting it via the `wit_AccessControlSK` witness. The module computes + * an account identifier as `persistentHash(secretKey)` which is a commitment that hides the key + * while providing a stable, pseudonymous on-chain identity. + * + * Because the account identifier is `H(secretKey)` with no per-deployment salt or domain + * separator, the same secret key produces the same identity across all contracts. This is + * intentional. It provides a linkable pseudonymous identity analogous to Solidity's + * `msg.sender`. Users who desire cross-contract unlinkability can use different secret keys + * per contract at the wallet layer. + * * Roles are referred to by their `Bytes<32>` identifier. These should be exposed * in the top-level contract and be unique. One way to achieve this is by * using `export sealed ledger` hash digests that are initialized in the top-level contract: * - * ```typescript + * ```compact * import CompactStandardLibrary; - * import "./node_modules/@openzeppelin-compact/accessControl/src/AccessControl" prefix AccessControl_; + * import "./node_modules/@myProject/accessControl/src/AccessControl" prefix AccessControl_; * * export sealed ledger MY_ROLE: Bytes<32>; * @@ -26,10 +37,10 @@ pragma language_version >= 0.21.0; * * To restrict access to a circuit, use {assertOnlyRole}: * - * ```typescript + * ```compact * circuit foo(): [] { - * assertOnlyRole(MY_ROLE); - * ... + * AccessControl_assertOnlyRole(MY_ROLE); + * // ... rest of circuit logic * } * ``` * @@ -47,7 +58,7 @@ pragma language_version >= 0.21.0; * grant and revoke this role. Extra precautions should be taken to secure * accounts that have been granted it. * - * @notice Roles can only be granted to ZswapCoinPublicKeys + * @notice Roles can only be granted to Bytes<32> identifiers * through the main role approval circuits (`grantRole` and `_grantRole`). * In other words, role approvals to contract addresses are disallowed through these * circuits. @@ -64,6 +75,16 @@ pragma language_version >= 0.21.0; * @notice The unsafe circuits are planned to become deprecated once contract-to-contract calls * are supported. * + * @dev Security Considerations: + * - The `secretKey` must be kept private. Loss of the key prevents role holders + * from proving access. Key exposure allows impersonation. + * - It is strongly recommended to use cryptographically secure random values for the secret key + * (e.g., `crypto.getRandomValues()`). Weak or predictable keys can be brute-forced. + * - The `secretKey` is provided via the `wit_AccessControlSK` witness. As with all witnesses, + * the contract must not assume that the witness implementation matches the developer's code. + * Any DApp may provide any implementation. The ZK proof system constrains what values + * can produce valid proofs. + * * @notice Missing Features and Improvements: * * - Role events @@ -71,18 +92,17 @@ pragma language_version >= 0.21.0; */ module AccessControl { import CompactStandardLibrary; - import "../utils/Utils" prefix Utils_; /** * @description Mapping from a role identifier -> account -> its permissions. * @type {Bytes<32>} roleId - A hash representing a role identifier. - * @type {Map, Boolean>} hasRole - A mapping from an account to a + * @type {Map, ContractAddress>, Boolean>} hasRole - A mapping from an account to a * Boolean determining if the account is approved for a role. * @type {Map} - * @type {Map, Map, Boolean>} _operatorRoles -  */ + * @type {Map, Map, ContractAddress>, Boolean>>} _operatorRoles + */ export ledger _operatorRoles: Map, - Map, Boolean>>; + Map, ContractAddress>, Boolean>>; /** * @description Mapping from a role identifier to an admin role identifier. @@ -90,22 +110,33 @@ module AccessControl { * @type {Bytes<32>} adminId - A hash representing an admin identifier. * @type {Map} * @type {Map, Bytes<32>>} _adminRoles -  */ + */ export ledger _adminRoles: Map, Bytes<32>>; export ledger DEFAULT_ADMIN_ROLE: Bytes<32>; + /** + * @witness wit_AccessControlSK + * @description Returns the caller's secret key used in deriving the account identifier. + * + * The same key produces the same account identifier across all contracts. Users who + * desire cross-contract unlinkability should use different keys per contract. + * + * @returns {Bytes<32>} secretKey - A 32-byte cryptographically secure random value. + */ + witness wit_AccessControlSK(): Bytes<32>; + /** * @description Returns `true` if `account` has been granted `roleId`. * - * @circuitInfo k=10, rows=487 + * @circuitInfo k=10, rows=1007 * * @param {Bytes<32>} roleId - The role identifier. - * @param {Either} account - The account to query. + * @param {Either, ContractAddress>} account - The account to query. * @return {Boolean} - Whether the account has the specified role. -   */ + */ export circuit hasRole(roleId: Bytes<32>, - account: Either + account: Either, ContractAddress> ): Boolean { if (_operatorRoles.member(disclose(roleId)) && _operatorRoles.lookup(roleId).member(disclose(account))) { @@ -116,38 +147,39 @@ module AccessControl { } /** - * @description Reverts if `ownPublicKey()` is missing `roleId`. + * @description Reverts if the caller cannot prove ownership of `roleId`. + * The caller's identity is derived from the `wit_AccessControlSK` witness as + * `persistentHash(secretKey)`. * - * @circuitInfo k=10, rows=345 + * @circuitInfo k=13, rows=2669 * * Requirements: * * - The caller must have `roleId`. - * - The caller must not be a ContractAddress * * @param {Bytes<32>} roleId - The role identifier. * @return {[]} - Empty tuple. */ export circuit assertOnlyRole(roleId: Bytes<32>): [] { - _checkRole(roleId, left(ownPublicKey())); + _checkRole(roleId, left, ContractAddress>(_computeAccountId())); } /** * @description Reverts if `account` is missing `roleId`. * - * @circuitInfo k=10, rows=467 + * @circuitInfo k=10, rows=987 * * Requirements: * * - `account` must have `roleId`. * * @param {Bytes<32>} roleId - The role identifier. - * @param {Either} account - The account to query. + * @param {Either, ContractAddress>} account - The account to query. * @return {[]} - Empty tuple. */ export circuit _checkRole( roleId: Bytes<32>, - account: Either + account: Either, ContractAddress> ): [] { assert(hasRole(roleId, account), "AccessControl: unauthorized account"); } @@ -156,9 +188,9 @@ module AccessControl { * @description Returns the admin role that controls `roleId` or * a byte array with all zero bytes if `roleId` doesn't exist. See {grantRole} and {revokeRole}. * - * To change a role’s admin use {_setRoleAdmin}. + * To change a role's admin use {_setRoleAdmin}. * - * @circuitInfo k=10, rows=207 + * @circuitInfo k=9, rows=373 * * @param {Bytes<32>} roleId - The role identifier. * @return {Bytes<32>} roleAdmin - The admin role that controls `roleId`. @@ -173,7 +205,7 @@ module AccessControl { /** * @description Grants `roleId` to `account`. * - * @circuitInfo k=10, rows=994 + * @circuitInfo k=13, rows=3578 * * Requirements: * @@ -181,12 +213,12 @@ module AccessControl { * - The caller must have `roleId`'s admin role. * * @param {Bytes<32>} roleId - The role identifier. - * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @param {Either, ContractAddress>} account - A Bytes<32> or ContractAddress. * @return {[]} - Empty tuple. */ export circuit grantRole( roleId: Bytes<32>, - account: Either + account: Either, ContractAddress> ): [] { assertOnlyRole(getRoleAdmin(roleId)); _grantRole(roleId, account); @@ -195,19 +227,19 @@ module AccessControl { /** * @description Revokes `roleId` from `account`. * - * @circuitInfo k=10, rows=827 + * @circuitInfo k=13, rows=3454 * * Requirements: * * - The caller must have `roleId`'s admin role. * * @param {Bytes<32>} roleId - The role identifier. - * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @param {Either, ContractAddress>} account - A Bytes<32> or ContractAddress. * @return {[]} - Empty tuple. */ export circuit revokeRole( roleId: Bytes<32>, - account: Either + account: Either, ContractAddress> ): [] { assertOnlyRole(getRoleAdmin(roleId)); _revokeRole(roleId, account); @@ -220,22 +252,25 @@ module AccessControl { * purpose is to provide a mechanism for accounts to lose their privileges * if they are compromised (such as when a trusted device is misplaced). * - * @circuitInfo k=10, rows=640 + * The caller's identity is derived from the `wit_AccessControlSK` witness as + * `persistentHash(secretKey)`. The `callerConfirmation` parameter must match + * the caller's computed account identifier to prevent accidental renunciation. + * + * @circuitInfo k=13, rows=3310 * * Requirements: * - * - The caller must be `callerConfirmation`. - * - The caller must not be a `ContractAddress`. + * - The caller's computed account identifier must match `callerConfirmation`. * * @param {Bytes<32>} roleId - The role identifier. - * @param {Either} callerConfirmation - A ZswapCoinPublicKey or ContractAddress. + * @param {Either, ContractAddress>} callerConfirmation - The caller's account identifier, must match the internally computed value. * @return {[]} - Empty tuple. */ export circuit renounceRole( roleId: Bytes<32>, - callerConfirmation: Either + callerConfirmation: Either, ContractAddress> ): [] { - assert(callerConfirmation == left(ownPublicKey()), + assert(callerConfirmation == left, ContractAddress>(_computeAccountId()), "AccessControl: bad confirmation" ); @@ -245,7 +280,7 @@ module AccessControl { /** * @description Sets `adminRole` as `roleId`'s admin role. * - * @circuitInfo k=10, rows=209 + * @circuitInfo k=10, rows=581 * * @param {Bytes<32>} roleId - The role identifier. * @param {Bytes<32>} adminRole - The admin role identifier. @@ -259,21 +294,22 @@ module AccessControl { * @description Attempts to grant `roleId` to `account` and returns a boolean indicating if `roleId` was granted. * Internal circuit without access restriction. * - * @circuitInfo k=10, rows=734 + * @circuitInfo k=11, rows=1185 * * Requirements: * * - `account` must not be a ContractAddress. * * @param {Bytes<32>} roleId - The role identifier. - * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @param {Either, ContractAddress>} account - A Bytes<32> or ContractAddress. * @return {Boolean} roleGranted - A boolean indicating if `roleId` was granted. */ export circuit _grantRole( roleId: Bytes<32>, - account: Either + account: Either, ContractAddress> ): Boolean { - assert(!Utils_isContractAddress(account), "AccessControl: unsafe role approval"); + const isCommitment = account.is_left; + assert(isCommitment, "AccessControl: unsafe role approval"); return _unsafeGrantRole(roleId, account); } @@ -281,18 +317,18 @@ module AccessControl { * @description Attempts to grant `roleId` to `account` and returns a boolean indicating if `roleId` was granted. * Internal circuit without access restriction. It does NOT check if the role is granted to a ContractAddress. * - * @circuitInfo k=10, rows=733 + * @circuitInfo k=11, rows=1184 * - * @notice External smart contracts cannot call the token contract at this time, so granting a role to an ContractAddress may + * @notice External smart contracts cannot call the token contract at this time, so granting a role to a ContractAddress may * render a circuit permanently inaccessible. * * @param {Bytes<32>} roleId - The role identifier. - * @param {Either} account - A ZswapCoinPublicKey or ContractAddress. + * @param {Either, ContractAddress>} account - A Bytes<32> or ContractAddress. * @return {Boolean} roleGranted - A boolean indicating if `role` was granted. */ export circuit _unsafeGrantRole( roleId: Bytes<32>, - account: Either + account: Either, ContractAddress> ): Boolean { if (hasRole(roleId, account)) { return false; @@ -301,7 +337,7 @@ module AccessControl { if (!_operatorRoles.member(disclose(roleId))) { _operatorRoles.insert( disclose(roleId), - default, Boolean>> + default, ContractAddress>, Boolean>> ); _operatorRoles.lookup(roleId).insert(disclose(account), true); return true; @@ -315,15 +351,15 @@ module AccessControl { * @description Attempts to revoke `roleId` from `account` and returns a boolean indicating if `roleId` was revoked. * Internal circuit without access restriction. * - * @circuitInfo k=10, rows=563 + * @circuitInfo k=11, rows=1061 * * @param {Bytes<32>} roleId - The role identifier. - * @param {Bytes<32>} adminRole - The admin role identifier. + * @param {Either, ContractAddress>} account - A Bytes<32> or ContractAddress. * @return {Boolean} roleRevoked - A boolean indicating if `roleId` was revoked. */ export circuit _revokeRole( roleId: Bytes<32>, - account: Either + account: Either, ContractAddress> ): Boolean { if (!hasRole(roleId, account)) { return false; @@ -332,4 +368,52 @@ module AccessControl { _operatorRoles.lookup(roleId).insert(disclose(account), false); return true; } + + /** + * @description Computes the caller's account identifier from the `wit_AccessControlSK` witness. + * + * ## ID Derivation + * `accountId = persistentHash(secretKey)` + * + * The result is a 32-byte commitment that uniquely identifies the caller. + * + * @circuitInfo k=13, rows=2294 + * + * @returns {Bytes<32>} accountId - The computed account identifier. + */ + circuit _computeAccountId(): Bytes<32> { + return computeAccountId(wit_AccessControlSK()); + } + + /** + * @description Computes an account identifier without on-chain state, allowing a user to derive + * their identity commitment before submitting it in a grant or revoke operation. + * This is the off-chain counterpart to {_computeAccountId} and produces an identical result + * given the same inputs. + * + * @warning OpSec: The `secretKey` parameter is a sensitive secret. Mishandling it can + * permanently compromise the privacy guarantees of this system: + * + * - **Never log or persist** the `secretKey` in plaintext — avoid browser devtools, + * application logs, analytics pipelines, or any observable side-channel. + * - **Store offline or in secure enclaves** — hardware security modules (HSMs), + * air-gapped devices, or encrypted vaults are strongly preferred over hot storage. + * - **Use cryptographically secure randomness** — generate keys with `crypto.getRandomValues()` + * or equivalent; weak or predictable keys can be brute-forced to reveal your identity. + * - **Treat key loss as identity loss** — a lost key cannot be recovered. Back up + * keys securely before using them in role commitments. + * - **Avoid calling this circuit in untrusted environments** — executing this in an + * unverified browser extension, compromised runtime, or shared machine may expose + * the key to a malicious observer. + * + * ## ID Derivation + * `accountId = persistentHash(secretKey)` + * + * @param {Bytes<32>} secretKey - A 32-byte cryptographically secure random value. + * + * @returns {Bytes<32>} accountId - The computed account identifier. + */ + export pure circuit computeAccountId(secretKey: Bytes<32>): Bytes<32> { + return persistentHash>>([secretKey]); + } } diff --git a/contracts/src/access/test/AccessControl.test.ts b/contracts/src/access/test/AccessControl.test.ts index d31040a0..5e0b7da2 100644 --- a/contracts/src/access/test/AccessControl.test.ts +++ b/contracts/src/access/test/AccessControl.test.ts @@ -1,21 +1,61 @@ -import { convertFieldToBytes } from '@midnight-ntwrk/compact-runtime'; +import { + CompactTypeBytes, + CompactTypeVector, + convertFieldToBytes, + persistentHash, +} from '@midnight-ntwrk/compact-runtime'; import { beforeEach, describe, expect, it } from 'vitest'; import * as utils from '#test-utils/address.js'; import { AccessControlSimulator } from './simulators/AccessControlSimulator.js'; -// PKs -const [OPERATOR_1, Z_OPERATOR_1] = utils.generateEitherPubKeyPair('OPERATOR_1'); -const [_, Z_OPERATOR_2] = utils.generateEitherPubKeyPair('OPERATOR_2'); -const [ADMIN, Z_ADMIN] = utils.generateEitherPubKeyPair('ADMIN'); -const [CUSTOM_ADMIN, Z_CUSTOM_ADMIN] = - utils.generateEitherPubKeyPair('CUSTOM_ADMIN'); -const [UNAUTHORIZED, Z_UNAUTHORIZED] = - utils.generateEitherPubKeyPair('UNAUTHORIZED'); +// Helpers +const buildAccountIdHash = (sk: Uint8Array): Uint8Array => { + const rt_type = new CompactTypeVector(1, new CompactTypeBytes(32)); + return persistentHash(rt_type, [sk]); +}; + +const zeroBytes = utils.zeroUint8Array(); -// Encoded contract addresses -const OPERATOR_CONTRACT = utils.toHexPadded('OPERATOR_CONTRACT'); -const Z_OPERATOR_CONTRACT = - utils.createEitherTestContractAddress('OPERATOR_CONTRACT'); +const eitherCommitment = (commitment: Uint8Array) => { + return { + is_left: true, + left: commitment, + right: { bytes: zeroBytes }, + }; +}; + +const eitherContract = (address: string) => { + return { + is_left: false, + left: zeroBytes, + right: utils.encodeToAddress(address), + }; +}; + +const createTestSK = (label: string): Uint8Array => { + const sk = new Uint8Array(32); + const encoded = new TextEncoder().encode(label); + sk.set(encoded.slice(0, 32)); + return sk; +}; + +const makeUser = (label: string) => { + const secretKey = createTestSK(label); + const accountId = buildAccountIdHash(secretKey); + const either = eitherCommitment(accountId); + return { secretKey, accountId, either }; +}; + +// Users +const ADMIN = makeUser('ADMIN'); +const CUSTOM_ADMIN = makeUser('CUSTOM_ADMIN'); +const OP1 = makeUser('OP1'); +const OP2 = makeUser('OP2'); +const OP3 = makeUser('OP3'); +const UNAUTHORIZED = makeUser('UNAUTHORIZED'); + +// Contract addresses +const OP1_CONTRACT = eitherContract('CONTRACT_ADDRESS'); // Roles const DEFAULT_ADMIN_ROLE = utils.zeroUint8Array(); @@ -25,21 +65,18 @@ const OPERATOR_ROLE_3 = convertFieldToBytes(32, 3n, ''); const CUSTOM_ADMIN_ROLE = convertFieldToBytes(32, 4n, ''); const UNINITIALIZED_ROLE = convertFieldToBytes(32, 5n, ''); -let accessControl: AccessControlSimulator; +// Lists +const operatorRolesList = [OPERATOR_ROLE_1, OPERATOR_ROLE_2]; +const commitmentOperators = [OP1.either, OP2.either, OP3.either]; +const allOperators = [...commitmentOperators, OP1_CONTRACT]; -const callerTypes = { - contract: OPERATOR_CONTRACT, - pubkey: OPERATOR_1, -}; +let accessControl: AccessControlSimulator; const operatorTypes = [ - ['contract', Z_OPERATOR_CONTRACT], - ['pubkey', Z_OPERATOR_1], + ['contract', OP1_CONTRACT], + ['commitment', OP1.either], ] as const; -const operatorRoles = [OPERATOR_ROLE_1, OPERATOR_ROLE_2]; -const operatorPKs = [Z_OPERATOR_1, Z_OPERATOR_2, Z_OPERATOR_CONTRACT]; - describe('AccessControl', () => { beforeEach(() => { accessControl = new AccessControlSimulator(); @@ -47,71 +84,71 @@ describe('AccessControl', () => { describe('hasRole', () => { beforeEach(() => { - accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + accessControl._grantRole(OPERATOR_ROLE_1, OP1.either); }); it('should return true when operator has a role', () => { - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); }); it('should return false when unauthorized', () => { - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_UNAUTHORIZED)).toBe( + expect(accessControl.hasRole(OPERATOR_ROLE_1, UNAUTHORIZED.either)).toBe( false, ); }); it('should return false when role does not exist', () => { - expect(accessControl.hasRole(UNINITIALIZED_ROLE, Z_OPERATOR_1)).toBe( - false, - ); + expect(accessControl.hasRole(UNINITIALIZED_ROLE, OP1.either)).toBe(false); }); }); describe('assertOnlyRole', () => { beforeEach(() => { - accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + accessControl._grantRole(OPERATOR_ROLE_1, OP1.either); }); it('should allow operator with role to call', () => { - expect(() => - accessControl.as(OPERATOR_1).assertOnlyRole(OPERATOR_ROLE_1), - ).not.toThrow(); - }); + // Set secret key for OP1 + accessControl.privateState.injectSecretKey(OP1.secretKey); - it('should throw if caller is unauthorized', () => { - expect(() => - accessControl.as(UNAUTHORIZED).assertOnlyRole(OPERATOR_ROLE_1), - ).toThrow('AccessControl: unauthorized account'); + expect(() => accessControl.assertOnlyRole(OPERATOR_ROLE_1)).not.toThrow(); }); - it('should throw if ContractAddress with role is caller', () => { - accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + it('should fail if caller is unauthorized', () => { + // Set bad secret key + accessControl.privateState.injectSecretKey(UNAUTHORIZED.secretKey); - expect(() => - accessControl.as(OPERATOR_CONTRACT).assertOnlyRole(OPERATOR_ROLE_1), - ).toThrow('AccessControl: unauthorized account'); + expect(() => accessControl.assertOnlyRole(OPERATOR_ROLE_1)).toThrow( + 'AccessControl: unauthorized account', + ); }); }); describe('_checkRole', () => { beforeEach(() => { - accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); - accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + accessControl._grantRole(OPERATOR_ROLE_1, OP1.either); + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, OP1_CONTRACT); }); - describe.each( - operatorTypes, - )('when the operator is a %s', (_operatorType, _operator) => { - it(`should not throw if ${_operatorType} has role`, () => { - expect(() => - accessControl._checkRole(OPERATOR_ROLE_1, _operator), - ).not.toThrow(); - }); + it('should not fail if user has role', () => { + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); + + expect(() => + accessControl._checkRole(OPERATOR_ROLE_1, OP1.either), + ).not.toThrow(); + }); + + it('should not fail if contract has role', () => { + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1_CONTRACT)).toBe(true); + + expect(() => + accessControl._checkRole(OPERATOR_ROLE_1, OP1_CONTRACT), + ).not.toThrow(); }); - it('should throw if operator is unauthorized', () => { + it('should fail if operator is unauthorized', () => { expect(() => - accessControl._checkRole(OPERATOR_ROLE_1, Z_UNAUTHORIZED), + accessControl._checkRole(OPERATOR_ROLE_1, UNAUTHORIZED.either), ).toThrow('AccessControl: unauthorized account'); }); }); @@ -133,73 +170,146 @@ describe('AccessControl', () => { describe('grantRole', () => { beforeEach(() => { - accessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); + accessControl._grantRole(DEFAULT_ADMIN_ROLE, ADMIN.either); }); it('admin should grant role', () => { - accessControl.as(ADMIN).grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + // Set admin SK + accessControl.privateState.injectSecretKey(ADMIN.secretKey); + + accessControl.grantRole(OPERATOR_ROLE_1, OP1.either); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); }); it('admin should grant multiple roles', () => { - for (let i = 0; i < operatorRoles.length; i++) { - // length - 1 because we test ContractAddress separately - for (let j = 0; j < operatorPKs.length - 1; j++) { - accessControl.as(ADMIN).grantRole(operatorRoles[i], operatorPKs[j]); - expect(accessControl.hasRole(operatorRoles[i], operatorPKs[j])).toBe( - true, - ); + // Set admin SK + accessControl.privateState.injectSecretKey(ADMIN.secretKey); + + for (let i = 0; i < operatorRolesList.length; i++) { + for (let j = 0; j < commitmentOperators.length; j++) { + accessControl.grantRole(operatorRolesList[i], commitmentOperators[j]); + expect( + accessControl.hasRole(operatorRolesList[i], commitmentOperators[j]), + ).toBe(true); } } }); - it('should throw if operator grants role', () => { - accessControl.as(ADMIN).grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + it('should fail if unauthorized grants role', () => { + // Set unauthorized SK + accessControl.privateState.injectSecretKey(UNAUTHORIZED.secretKey); expect(() => { - accessControl.as(OPERATOR_1).grantRole(OPERATOR_ROLE_1, Z_UNAUTHORIZED); + accessControl.grantRole(OPERATOR_ROLE_1, OP1.either); }).toThrow('AccessControl: unauthorized account'); }); - it('should throw if admin grants role to ContractAddress', () => { + it('should fail if operator grants role', () => { + // Set admin SK + accessControl.privateState.injectSecretKey(ADMIN.secretKey); + accessControl.grantRole(OPERATOR_ROLE_1, OP1.either); + + // Set OP1 SK + accessControl.privateState.injectSecretKey(OP1.secretKey); + + expect(() => { + accessControl.grantRole(OPERATOR_ROLE_1, OP2.either); + }).toThrow('AccessControl: unauthorized account'); + }); + + it('should fail if admin grants role to ContractAddress', () => { + // Set admin SK + accessControl.privateState.injectSecretKey(ADMIN.secretKey); + expect(() => { - accessControl.as(ADMIN).grantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + accessControl.grantRole(OPERATOR_ROLE_1, OP1_CONTRACT); }).toThrow('AccessControl: unsafe role approval'); }); + + it('admin should not be able to grant after self-revocation', () => { + // Set admin SK + accessControl.privateState.injectSecretKey(ADMIN.secretKey); + + accessControl.revokeRole(DEFAULT_ADMIN_ROLE, ADMIN.either); + + expect(() => accessControl.grantRole(OPERATOR_ROLE_1, OP1.either)).toThrow( + 'AccessControl: unauthorized account', + ); + }); + + it('admin should not be able to grant after renouncing role', () => { + // Set admin SK + accessControl.privateState.injectSecretKey(ADMIN.secretKey); + + accessControl.renounceRole(DEFAULT_ADMIN_ROLE, ADMIN.either); + + expect(() => accessControl.grantRole(OPERATOR_ROLE_1, OP1.either)).toThrow( + 'AccessControl: unauthorized account', + ); + }); + + it('admin authority should not be transitive across role hierarchies', () => { + accessControl._setRoleAdmin(OPERATOR_ROLE_2, OPERATOR_ROLE_1); + accessControl._grantRole(OPERATOR_ROLE_1, OP1.either); + + accessControl.privateState.injectSecretKey(ADMIN.secretKey); + + // ADMIN holds DEFAULT_ADMIN_ROLE but not OPERATOR_ROLE_1 + expect(() => accessControl.grantRole(OPERATOR_ROLE_2, OP2.either)).toThrow( + 'AccessControl: unauthorized account', + ); + + // OP1 holds OPERATOR_ROLE_1 which is admin of OPERATOR_ROLE_2 + accessControl.privateState.injectSecretKey(OP1.secretKey); + expect(() => accessControl.grantRole(OPERATOR_ROLE_2, OP2.either)).not.toThrow(); + }); }); describe('revokeRole', () => { beforeEach(() => { - accessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); - accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); - accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + accessControl._grantRole(DEFAULT_ADMIN_ROLE, ADMIN.either); + accessControl._grantRole(OPERATOR_ROLE_1, OP1.either); + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, OP1_CONTRACT); }); describe.each( operatorTypes, )('when the operator is a %s', (_operatorType, _operator) => { it('admin should revoke role', () => { - accessControl.as(ADMIN).revokeRole(OPERATOR_ROLE_1, _operator); + // Set admin SK + accessControl.privateState.injectSecretKey(ADMIN.secretKey); + + accessControl.revokeRole(OPERATOR_ROLE_1, _operator); expect(accessControl.hasRole(OPERATOR_ROLE_1, _operator)).toBe(false); }); + }); - it('should throw if operator revokes role', () => { - const caller = callerTypes[_operatorType]; + it('should fail if unauthorized revokes role', () => { + accessControl.privateState.injectSecretKey(UNAUTHORIZED.secretKey); - expect(() => { - accessControl.as(caller).revokeRole(OPERATOR_ROLE_1, Z_UNAUTHORIZED); - }).toThrow('AccessControl: unauthorized account'); - }); + expect(() => { + accessControl.revokeRole(OPERATOR_ROLE_1, OP1.either); + }).toThrow('AccessControl: unauthorized account'); + }); + + it('should fail if operator revokes role', () => { + accessControl.privateState.injectSecretKey(OP1.secretKey); + + expect(() => { + accessControl.revokeRole(OPERATOR_ROLE_1, OP2.either); + }).toThrow('AccessControl: unauthorized account'); }); it('admin should revoke multiple roles', () => { - for (let i = 0; i < operatorRoles.length; i++) { - for (let j = 0; j < operatorPKs.length; j++) { - accessControl._unsafeGrantRole(operatorRoles[i], operatorPKs[j]); - accessControl.as(ADMIN).revokeRole(operatorRoles[i], operatorPKs[j]); - expect(accessControl.hasRole(operatorRoles[i], operatorPKs[j])).toBe( - false, - ); + accessControl.privateState.injectSecretKey(ADMIN.secretKey); + + for (let i = 0; i < operatorRolesList.length; i++) { + for (let j = 0; j < allOperators.length; j++) { + accessControl._unsafeGrantRole(operatorRolesList[i], allOperators[j]); + accessControl.revokeRole(operatorRolesList[i], allOperators[j]); + expect( + accessControl.hasRole(operatorRolesList[i], allOperators[j]), + ).toBe(false); } } }); @@ -207,31 +317,43 @@ describe('AccessControl', () => { describe('renounceRole', () => { beforeEach(() => { - accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + accessControl._grantRole(OPERATOR_ROLE_1, OP1.either); }); it('should allow operator to renounce own role', () => { - accessControl.as(OPERATOR_1).renounceRole(OPERATOR_ROLE_1, Z_OPERATOR_1); - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(false); + accessControl.privateState.injectSecretKey(OP1.secretKey); + + accessControl.renounceRole(OPERATOR_ROLE_1, OP1.either); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(false); }); - it('ContractAddress renounce should throw', () => { - accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + // Should be refactored with c2c + it('should fail when renouncing as a ContractAddress', () => { + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, OP1_CONTRACT); + + accessControl.privateState.injectSecretKey(ADMIN.secretKey); expect(() => { - accessControl - .as(OPERATOR_CONTRACT) - .renounceRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + accessControl.renounceRole(OPERATOR_ROLE_1, OP1_CONTRACT); }).toThrow('AccessControl: bad confirmation'); }); - it('unauthorized renounce should throw', () => { + it('should fail when unauthorized renounces role', () => { + accessControl.privateState.injectSecretKey(UNAUTHORIZED.secretKey); + expect(() => { - accessControl - .as(UNAUTHORIZED) - .renounceRole(OPERATOR_ROLE_1, Z_OPERATOR_1); + accessControl.renounceRole(OPERATOR_ROLE_1, OP1.either); }).toThrow('AccessControl: bad confirmation'); }); + + it('should not fail when renouncing a role not held', () => { + accessControl.privateState.injectSecretKey(OP1.secretKey); + // Confirm role not already held + expect(accessControl.hasRole(OPERATOR_ROLE_3, OP1.either)).toBe(false); + + accessControl.renounceRole(OPERATOR_ROLE_3, OP1.either); + expect(accessControl.hasRole(OPERATOR_ROLE_3, OP1.either)).toBe(false); + }); }); describe('_setRoleAdmin', () => { @@ -261,110 +383,128 @@ describe('AccessControl', () => { }); it('should authorize new admin to grant / revoke roles', () => { - accessControl._grantRole(CUSTOM_ADMIN_ROLE, Z_CUSTOM_ADMIN); + accessControl._grantRole(CUSTOM_ADMIN_ROLE, CUSTOM_ADMIN.either); accessControl._setRoleAdmin(OPERATOR_ROLE_1, CUSTOM_ADMIN_ROLE); + // Set custom admin SK + accessControl.privateState.injectSecretKey(CUSTOM_ADMIN.secretKey); + + // Grant role and check it's been granted expect(() => - accessControl.as(CUSTOM_ADMIN).grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1), + accessControl.grantRole(OPERATOR_ROLE_1, OP1.either), ).not.toThrow(); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); + + // Revoke role and check it's been revoked expect(() => - accessControl - .as(CUSTOM_ADMIN) - .revokeRole(OPERATOR_ROLE_1, Z_OPERATOR_1), + accessControl.revokeRole(OPERATOR_ROLE_1, OP1.either), ).not.toThrow(); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(false); }); it('should disallow previous admin from granting / revoking roles', () => { - accessControl._grantRole(DEFAULT_ADMIN_ROLE, Z_ADMIN); - accessControl._grantRole(CUSTOM_ADMIN_ROLE, Z_CUSTOM_ADMIN); + accessControl._grantRole(DEFAULT_ADMIN_ROLE, ADMIN.either); + accessControl._grantRole(CUSTOM_ADMIN_ROLE, CUSTOM_ADMIN.either); accessControl._setRoleAdmin(OPERATOR_ROLE_1, CUSTOM_ADMIN_ROLE); - expect(() => - accessControl.as(ADMIN).grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1), - ).toThrow('AccessControl: unauthorized account'); - expect(() => - accessControl.as(ADMIN).revokeRole(OPERATOR_ROLE_1, Z_OPERATOR_1), - ).toThrow('AccessControl: unauthorized account'); + // Set init admin + accessControl.privateState.injectSecretKey(ADMIN.secretKey); + + expect(() => { + accessControl.grantRole(OPERATOR_ROLE_1, OP1.either); + }).toThrow('AccessControl: unauthorized account'); + + expect(() => { + accessControl.revokeRole(OPERATOR_ROLE_1, OP1.either); + }).toThrow('AccessControl: unauthorized account'); }); }); describe('_grantRole', () => { it('should grant role', () => { - expect(accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe( - true, - ); - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + expect(accessControl._grantRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); }); it('should return false if hasRole already', () => { - expect(accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe( - true, - ); - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + expect(accessControl._grantRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); - expect(accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe( - false, - ); - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + expect(accessControl._grantRole(OPERATOR_ROLE_1, OP1.either)).toBe(false); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); }); + // Should be refactored with c2c it('should fail to grant role to a ContractAddress', () => { expect(() => { - accessControl._grantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT); + accessControl._grantRole(OPERATOR_ROLE_1, OP1_CONTRACT); }).toThrow('AccessControl: unsafe role approval'); }); it('should grant multiple roles', () => { - for (let i = 0; i < operatorRoles.length; i++) { - // length - 1 because we test ContractAddress in the above test - for (let j = 0; j < operatorPKs.length - 1; j++) { - accessControl._grantRole(operatorRoles[i], operatorPKs[j]); - expect(accessControl.hasRole(operatorRoles[i], operatorPKs[j])).toBe( - true, + for (let i = 0; i < operatorRolesList.length; i++) { + for (let j = 0; j < commitmentOperators.length; j++) { + accessControl._grantRole( + operatorRolesList[i], + commitmentOperators[j], ); + expect( + accessControl.hasRole(operatorRolesList[i], commitmentOperators[j]), + ).toBe(true); } } }); + + it('should allow regranting a revoked role', () => { + accessControl._grantRole(OPERATOR_ROLE_1, OP1.either); + accessControl._revokeRole(OPERATOR_ROLE_1, OP1.either); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(false); + + expect(accessControl._grantRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); + }); }); describe('_unsafeGrantRole', () => { it('should grant role', () => { - expect( - accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_1), - ).toBe(true); - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + expect(accessControl._unsafeGrantRole(OPERATOR_ROLE_1, OP1.either)).toBe( + true, + ); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); }); it('should return false if hasRole already', () => { - expect( - accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_1), - ).toBe(true); - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + expect(accessControl._unsafeGrantRole(OPERATOR_ROLE_1, OP1.either)).toBe( + true, + ); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); - expect( - accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_1), - ).toBe(false); - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe(true); + expect(accessControl._unsafeGrantRole(OPERATOR_ROLE_1, OP1.either)).toBe( + false, + ); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1.either)).toBe(true); }); + // Should be refactored with c2c it('should grant role to a ContractAddress', () => { expect( - accessControl._unsafeGrantRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT), + accessControl._unsafeGrantRole(OPERATOR_ROLE_1, OP1_CONTRACT), ).toBe(true); - expect(accessControl.hasRole(OPERATOR_ROLE_1, Z_OPERATOR_CONTRACT)).toBe( - true, - ); + expect(accessControl.hasRole(OPERATOR_ROLE_1, OP1_CONTRACT)).toBe(true); }); it('should grant multiple roles', () => { - for (let i = 0; i < operatorRoles.length; i++) { - for (let j = 0; j < operatorPKs.length; j++) { + for (let i = 0; i < operatorRolesList.length; i++) { + for (let j = 0; j < allOperators.length; j++) { expect( - accessControl._unsafeGrantRole(operatorRoles[i], operatorPKs[j]), + accessControl._unsafeGrantRole( + operatorRolesList[i], + allOperators[j], + ), + ).toBe(true); + expect( + accessControl.hasRole(operatorRolesList[i], allOperators[j]), ).toBe(true); - expect(accessControl.hasRole(operatorRoles[i], operatorPKs[j])).toBe( - true, - ); } } }); @@ -384,23 +524,35 @@ describe('AccessControl', () => { }); it('should return false if account does not have role', () => { - expect(accessControl._revokeRole(OPERATOR_ROLE_1, Z_OPERATOR_1)).toBe( + expect(accessControl._revokeRole(OPERATOR_ROLE_1, OP1.either)).toBe( false, ); }); it('should revoke multiple roles', () => { - for (let i = 0; i < operatorRoles.length; i++) { - for (let j = 0; j < operatorPKs.length; j++) { - accessControl._unsafeGrantRole(operatorRoles[i], operatorPKs[j]); + for (let i = 0; i < operatorRolesList.length; i++) { + for (let j = 0; j < allOperators.length; j++) { + accessControl._unsafeGrantRole(operatorRolesList[i], allOperators[j]); expect( - accessControl._revokeRole(operatorRoles[i], operatorPKs[j]), + accessControl._revokeRole(operatorRolesList[i], allOperators[j]), ).toBe(true); - expect(accessControl.hasRole(operatorRoles[i], operatorPKs[j])).toBe( - false, - ); + expect( + accessControl.hasRole(operatorRolesList[i], allOperators[j]), + ).toBe(false); } } }); }); + + describe('computeAccountId', () => { + it('should match the test helper derivation', () => { + const users = [OP1, OP2, OP3]; + + for (let i = 0; i < users.length; i++) { + expect(accessControl.computeAccountId(users[i].secretKey)).toEqual( + users[i].accountId, + ); + } + }); + }); }); diff --git a/contracts/src/access/test/mocks/MockAccessControl.compact b/contracts/src/access/test/mocks/MockAccessControl.compact index 57695020..d79b3401 100644 --- a/contracts/src/access/test/mocks/MockAccessControl.compact +++ b/contracts/src/access/test/mocks/MockAccessControl.compact @@ -11,9 +11,9 @@ import CompactStandardLibrary; import "../../AccessControl" prefix AccessControl_; -export { ZswapCoinPublicKey, ContractAddress, Either, Maybe, AccessControl_DEFAULT_ADMIN_ROLE }; +export { ContractAddress, Either, Maybe, AccessControl_DEFAULT_ADMIN_ROLE }; -export circuit hasRole(roleId: Bytes<32>, account: Either): Boolean { +export circuit hasRole(roleId: Bytes<32>, account: Either, ContractAddress>): Boolean { return AccessControl_hasRole(roleId, account); } @@ -21,7 +21,7 @@ export circuit assertOnlyRole(roleId: Bytes<32>): [] { AccessControl_assertOnlyRole(roleId); } -export circuit _checkRole(roleId: Bytes<32>, account: Either): [] { +export circuit _checkRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { AccessControl__checkRole(roleId, account); } @@ -29,15 +29,15 @@ export circuit getRoleAdmin(roleId: Bytes<32>): Bytes<32> { return AccessControl_getRoleAdmin(roleId); } -export circuit grantRole(roleId: Bytes<32>, account: Either): [] { +export circuit grantRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { AccessControl_grantRole(roleId, account); } -export circuit revokeRole(roleId: Bytes<32>, account: Either): [] { +export circuit revokeRole(roleId: Bytes<32>, account: Either, ContractAddress>): [] { AccessControl_revokeRole(roleId, account); } -export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Either): [] { +export circuit renounceRole(roleId: Bytes<32>, callerConfirmation: Either, ContractAddress>): [] { AccessControl_renounceRole(roleId, callerConfirmation); } @@ -45,14 +45,18 @@ export circuit _setRoleAdmin(roleId: Bytes<32>, adminRole: Bytes<32>): [] { AccessControl__setRoleAdmin(roleId, adminRole); } -export circuit _grantRole(roleId: Bytes<32>, account: Either): Boolean { +export circuit _grantRole(roleId: Bytes<32>, account: Either, ContractAddress>): Boolean { return AccessControl__grantRole(roleId, account); } -export circuit _unsafeGrantRole(roleId: Bytes<32>, account: Either): Boolean { +export circuit _unsafeGrantRole(roleId: Bytes<32>, account: Either, ContractAddress>): Boolean { return AccessControl__unsafeGrantRole(roleId, account); } -export circuit _revokeRole(roleId: Bytes<32>, account: Either): Boolean { +export circuit _revokeRole(roleId: Bytes<32>, account: Either, ContractAddress>): Boolean { return AccessControl__revokeRole(roleId, account); } + +export pure circuit computeAccountId(secretKey: Bytes<32>): Bytes<32> { + return AccessControl_computeAccountId(secretKey); +} diff --git a/contracts/src/access/test/simulators/AccessControlSimulator.ts b/contracts/src/access/test/simulators/AccessControlSimulator.ts index 8d8f06e8..c569b100 100644 --- a/contracts/src/access/test/simulators/AccessControlSimulator.ts +++ b/contracts/src/access/test/simulators/AccessControlSimulator.ts @@ -7,7 +7,6 @@ import { type Either, ledger, Contract as MockAccessControl, - type ZswapCoinPublicKey, } from '../../../../artifacts/MockAccessControl/contract/index.js'; import { AccessControlPrivateState, @@ -28,7 +27,7 @@ const AccessControlSimulatorBase = createSimulator< >({ contractFactory: (witnesses) => new MockAccessControl(witnesses), - defaultPrivateState: () => AccessControlPrivateState, + defaultPrivateState: () => AccessControlPrivateState.generate(), contractArgs: () => [], ledgerExtractor: (state) => ledger(state), witnessesFactory: () => AccessControlWitnesses(), @@ -55,7 +54,7 @@ export class AccessControlSimulator extends AccessControlSimulatorBase { */ public hasRole( roleId: Uint8Array, - account: Either, + account: Either, ): boolean { return this.circuits.impure.hasRole(roleId, account); } @@ -75,7 +74,7 @@ export class AccessControlSimulator extends AccessControlSimulatorBase { */ public _checkRole( roleId: Uint8Array, - account: Either, + account: Either, ) { this.circuits.impure._checkRole(roleId, account); } @@ -96,7 +95,7 @@ export class AccessControlSimulator extends AccessControlSimulatorBase { */ public grantRole( roleId: Uint8Array, - account: Either, + account: Either, ) { this.circuits.impure.grantRole(roleId, account); } @@ -108,7 +107,7 @@ export class AccessControlSimulator extends AccessControlSimulatorBase { */ public revokeRole( roleId: Uint8Array, - account: Either, + account: Either, ) { this.circuits.impure.revokeRole(roleId, account); } @@ -120,7 +119,7 @@ export class AccessControlSimulator extends AccessControlSimulatorBase { */ public renounceRole( roleId: Uint8Array, - account: Either, + account: Either, ) { this.circuits.impure.renounceRole(roleId, account); } @@ -141,7 +140,7 @@ export class AccessControlSimulator extends AccessControlSimulatorBase { */ public _grantRole( roleId: Uint8Array, - account: Either, + account: Either, ): boolean { return this.circuits.impure._grantRole(roleId, account); } @@ -154,7 +153,7 @@ export class AccessControlSimulator extends AccessControlSimulatorBase { */ public _unsafeGrantRole( roleId: Uint8Array, - account: Either, + account: Either, ): boolean { return this.circuits.impure._unsafeGrantRole(roleId, account); } @@ -166,8 +165,46 @@ export class AccessControlSimulator extends AccessControlSimulatorBase { */ public _revokeRole( roleId: Uint8Array, - account: Either, + account: Either, ): boolean { return this.circuits.impure._revokeRole(roleId, account); } + + /** + * @description Computes an account identifier without on-chain state, allowing a user to derive + * their identity commitment before submitting it in a grant or revoke operation. + * @param {Bytes<32>} secretKey - A 32-byte cryptographically secure random value. + * @returns {Bytes<32>} accountId - The computed account identifier. + */ + public computeAccountId(secretKey: Uint8Array): Uint8Array { + return this.circuits.pure.computeAccountId(secretKey); + } + + public readonly privateState = { + /** + * @description Replaces the secret key in the private state. Used in tests to + * simulate switching between different user identities or injecting incorrect + * keys to test failure paths. + * @param newSK - The new secret key to set. + * @returns The updated private state. + */ + injectSecretKey: (newSK: Uint8Array): AccessControlPrivateState => { + const updatedState = { secretKey: newSK }; + this.circuitContextManager.updatePrivateState(updatedState); + return updatedState; + }, + + /** + * @description Returns the current secret key from the private state. + * @returns The secret key. + * @throws If the secret key is undefined. + */ + getCurrentSecretKey: (): Uint8Array => { + const sk = this.getPrivateState().secretKey; + if (typeof sk === 'undefined') { + throw new Error('Missing secret key'); + } + return sk; + }, + }; } diff --git a/contracts/src/access/witnesses/AccessControlWitnesses.ts b/contracts/src/access/witnesses/AccessControlWitnesses.ts index d73ecf81..b42c8747 100644 --- a/contracts/src/access/witnesses/AccessControlWitnesses.ts +++ b/contracts/src/access/witnesses/AccessControlWitnesses.ts @@ -1,6 +1,73 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.0.1-alpha.1 (access/witnesses/AccessControlWitnesses.ts) +import { getRandomValues } from 'node:crypto'; +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import type { Ledger } from '../../../artifacts/MockAccessControl/contract/index.js'; -export type AccessControlPrivateState = Record; -export const AccessControlPrivateState: AccessControlPrivateState = {}; -export const AccessControlWitnesses = () => ({}); +/** + * @description Interface defining the witness methods for AccessControl operations. + * @template P - The private state type. + */ +export interface IAccessControlWitnesses

{ + /** + * Retrieves the secret key from the private state. + * @param context - The witness context containing the private state. + * @returns A tuple of the private state and the secret key as a Uint8Array. + */ + wit_AccessControlSK(context: WitnessContext): [P, Uint8Array]; +} + +/** + * @description Represents the private state of an AccessControl contract, storing a secret key. + */ +export type AccessControlPrivateState = { + /** @description A 32-byte secret key used for creating a public user identifier. */ + secretKey: Uint8Array; +}; + +/** + * @description Utility object for managing the private state of an AccessControl contract. + */ +export const AccessControlPrivateState = { + /** + * @description Generates a new private state with a random secret key. + * @returns A fresh AccessControlPrivateState instance. + */ + generate: (): AccessControlPrivateState => { + return { secretKey: getRandomValues(new Uint8Array(32)) }; + }, + + /** + * @description Generates a new private state with a user-defined secret key. + * Useful for deterministic nonce generation or advanced use cases. + * + * @param sk - The 32-byte secret nonce to use. + * @returns A fresh AccessControlPrivateState instance with the provided nonce. + * + * @example + * ```typescript + * // For deterministic keys (user-defined scheme) + * const deterministicKey = myDeterministicScheme(...); + * const privateState = AccessControlPrivateState.withSecretKey(deterministicKey); + * ``` + */ + withSecretKey: (sk: Uint8Array): AccessControlPrivateState => { + if (sk.length !== 32) { + throw new Error( + `withSecretKey: expected 32-byte secret key, received ${sk.length} bytes`, + ); + } + return { secretKey: Uint8Array.from(sk) }; + }, +}; + +/** + * @description Factory function creating witness implementations for Ownable operations. + * @returns An object implementing the Witnesses interface for AccessControlPrivateState. + */ +export const AccessControlWitnesses = + (): IAccessControlWitnesses => ({ + wit_AccessControlSK( + context: WitnessContext, + ): [AccessControlPrivateState, Uint8Array] { + return [context.privateState, context.privateState.secretKey]; + }, + }); diff --git a/contracts/src/access/witnesses/test/AccessControlWitnesses.test.ts b/contracts/src/access/witnesses/test/AccessControlWitnesses.test.ts new file mode 100644 index 00000000..89e94aaa --- /dev/null +++ b/contracts/src/access/witnesses/test/AccessControlWitnesses.test.ts @@ -0,0 +1,138 @@ +import type { WitnessContext } from '@midnight-ntwrk/compact-runtime'; +import { describe, expect, it } from 'vitest'; +import type { Ledger } from '../../../../artifacts/MockAccessControl/contract/index.js'; +import { + AccessControlPrivateState, + AccessControlWitnesses, +} from '../AccessControlWitnesses.js'; + +const SECRET_KEY = new Uint8Array(32).fill(0x34); + +describe('AccessControlPrivateState', () => { + describe('generate', () => { + it('should return a state with a 32-byte secretKey', () => { + const state = AccessControlPrivateState.generate(); + expect(state.secretKey).toBeInstanceOf(Uint8Array); + expect(state.secretKey.length).toBe(32); + }); + + it('should produce unique secret key on successive calls', () => { + const a = AccessControlPrivateState.generate(); + const b = AccessControlPrivateState.generate(); + expect(a.secretKey).not.toEqual(b.secretKey); + }); + }); + + describe('withSecretKey', () => { + it('should accept a valid 32-byte secret key', () => { + const state = AccessControlPrivateState.withSecretKey(SECRET_KEY); + expect(state.secretKey).toEqual(SECRET_KEY); + }); + + it('should create a defensive copy of the input secret key', () => { + const sk = new Uint8Array(32).fill(0xcc); + const state = AccessControlPrivateState.withSecretKey(sk); + + sk.fill(0xff); + expect(state.secretKey).toEqual(new Uint8Array(32).fill(0xcc)); + }); + + it('should throw for a secret key shorter than 32 bytes', () => { + const short = new Uint8Array(16); + expect(() => AccessControlPrivateState.withSecretKey(short)).toThrowError( + 'withSecretKey: expected 32-byte secret key, received 16 bytes', + ); + }); + + it('should throw for a secret key longer than 32 bytes', () => { + const long = new Uint8Array(64); + expect(() => AccessControlPrivateState.withSecretKey(long)).toThrowError( + 'withSecretKey: expected 32-byte secret key, received 64 bytes', + ); + }); + + it('should throw for an empty array', () => { + expect(() => + AccessControlPrivateState.withSecretKey(new Uint8Array(0)), + ).toThrowError( + 'withSecretKey: expected 32-byte secret key, received 0 bytes', + ); + }); + }); +}); + +describe('AccessControlWitnesses', () => { + const witnesses = AccessControlWitnesses(); + + function makeContext( + privateState: AccessControlPrivateState, + ): WitnessContext { + return { privateState } as WitnessContext< + Ledger, + AccessControlPrivateState + >; + } + + describe('wit_AccessControlSK', () => { + it('should return a tuple of [privateState, secretKey]', () => { + const state = AccessControlPrivateState.withSecretKey(SECRET_KEY); + const ctx = makeContext(state); + + const [returnedState, returnedSK] = witnesses.wit_AccessControlSK(ctx); + + expect(returnedState).toBe(state); + expect(returnedSK).toEqual(SECRET_KEY); + }); + + it('should return the exact same privateState reference', () => { + const state = AccessControlPrivateState.generate(); + const ctx = makeContext(state); + + const [returnedState] = witnesses.wit_AccessControlSK(ctx); + expect(returnedState).toBe(state); + }); + + it('should return the secretKey as a Uint8Array', () => { + const state = AccessControlPrivateState.generate(); + const ctx = makeContext(state); + + const [, returnedSK] = witnesses.wit_AccessControlSK(ctx); + expect(returnedSK).toBeInstanceOf(Uint8Array); + expect(returnedSK.length).toBe(32); + }); + + it('should work with a randomly generated state', () => { + const state = AccessControlPrivateState.generate(); + const ctx = makeContext(state); + + const [returnedState, returnedSK] = witnesses.wit_AccessControlSK(ctx); + + expect(returnedState).toBe(state); + expect(returnedSK).toEqual(state.secretKey); + }); + }); +}); + +describe('AccessControlWitnesses factory', () => { + it('should return a fresh witnesses object on each call', () => { + const a = AccessControlWitnesses(); + const b = AccessControlWitnesses(); + expect(a).not.toBe(b); + }); + + it('should produce witnesses with identical behaviour', () => { + const a = AccessControlWitnesses(); + const b = AccessControlWitnesses(); + const state = AccessControlPrivateState.generate(); + const ctx = { privateState: state } as WitnessContext< + Ledger, + AccessControlPrivateState + >; + + const [stateA, skA] = a.wit_AccessControlSK(ctx); + const [stateB, skB] = b.wit_AccessControlSK(ctx); + + expect(stateA).toBe(stateB); + expect(skA).toEqual(skB); + }); +});