|
| 1 | +// SPDX-License-Identifier: MIT |
| 2 | + |
| 3 | +pragma language_version >= 0.16.0; |
| 4 | + |
| 5 | +/** |
| 6 | + * @module ZOwnablePK |
| 7 | + * @description A shielded, public key-derived Ownable module. |
| 8 | + * |
| 9 | + * `ZOwnablePK` provides a privacy-preserving access control mechanism |
| 10 | + * for contracts with a single administrative user. Unlike traditional |
| 11 | + * `Ownable` implementations that store or expose the owner's public key |
| 12 | + * on-chain, this module stores only a commitment to a hashed identifier |
| 13 | + * derived from the owner's public key and a secret nonce. |
| 14 | + * For the strongest security guarantees, use an Air-Gapped Public Key. |
| 15 | + * |
| 16 | + * @notice This module explicitly supports commitments derived from public keys; |
| 17 | + * however, it may be possible to use contract addresses when contract-to-contract |
| 18 | + * calls become available. This will be revisited when it's known if/how witnesses |
| 19 | + * are used from a contract address context. |
| 20 | + * |
| 21 | + * @dev Features: |
| 22 | + * - Obfuscated owner identity: The owner's public key is never revealed on-chain. |
| 23 | + * - Stateless verification: The contract never needs access to the full public key. |
| 24 | + * - Built-in support for transfer and renounce functionality. |
| 25 | + * - Instance-specific salts to prevent cross-contract correlation. |
| 26 | + * - Deterministic hashing with `persistentHash` to support zero-knowledge verification. |
| 27 | + * |
| 28 | + * @dev Commitment structure: |
| 29 | + * ``` |
| 30 | + * id = SHA256(pk, secretNonce) |
| 31 | + * commitment = SHA256(id, instanceSalt, counter, "ZOwnablePK:shield:") |
| 32 | + * ``` |
| 33 | + * The commitment changes on each transfer due to the incrementing `counter`, |
| 34 | + * providing unlinkability across ownership changes. |
| 35 | + * |
| 36 | + * @dev Security Considerations: |
| 37 | + * - The `secretNonce` must be kept private. Loss of the nonce prevents the |
| 38 | + * owner from proving ownership or transferring it. |
| 39 | + * - Ownership validation is entirely circuit-based using witness-provided values. |
| 40 | + * - The `_instanceSalt` is immutable and used to differentiate deployments. |
| 41 | + * |
| 42 | + * @notice Best used for single-admin contracts with privacy requirements. |
| 43 | + * It is not designed for multi-owner or role-based access control. |
| 44 | + */ |
| 45 | +module ZOwnablePK { |
| 46 | + import CompactStandardLibrary; |
| 47 | + import "../security/Initializable" prefix Initializable_; |
| 48 | + |
| 49 | + /** |
| 50 | + * @ledger _ownerCommitment |
| 51 | + * @description Stores the current hashed commitment representing the owner. |
| 52 | + * This commitment is derived from the public identifier (e.g., `SHA256(pk, nonce)`), |
| 53 | + * the `instanceSalt`, the transfer `counter`, and a domain separator. |
| 54 | + * |
| 55 | + * A commitment of `default<Bytes<32>>` (i.e., zero) indicates the contract is unowned. |
| 56 | + */ |
| 57 | + export ledger _ownerCommitment: Bytes<32>; |
| 58 | + /** |
| 59 | + * @ledger _counter |
| 60 | + * @description Internal transfer counter used to prevent commitment reuse. |
| 61 | + * |
| 62 | + * Increments by 1 on every successful ownership transfer. Combined with `id` and |
| 63 | + * `instanceSalt` to compute unique owner commitments over time. |
| 64 | + */ |
| 65 | + export ledger _counter: Counter; |
| 66 | + /** |
| 67 | + * @sealed @ledger _instanceSalt |
| 68 | + * @description A per-instance value provided at initialization used to namespace |
| 69 | + * commitments for this contract instance. |
| 70 | + * |
| 71 | + * This salt prevents commitment collisions across contracts that might otherwise use |
| 72 | + * the same owner identifiers or domain parameters. It is immutable after initialization. |
| 73 | + */ |
| 74 | + export sealed ledger _instanceSalt: Bytes<32>; |
| 75 | + |
| 76 | + /** |
| 77 | + * @witness wit_secretNonce |
| 78 | + * @description A private per-user nonce used in deriving the shielded owner identifier. |
| 79 | + * |
| 80 | + * Combined with the user's public key as `SHA256(pk, nonce)` to produce an obfuscated, |
| 81 | + * unlinkable identity commitment. Users are encouraged to rotate this value on ownership changes. |
| 82 | + */ |
| 83 | + export witness wit_secretNonce(): Bytes<32>; |
| 84 | + |
| 85 | + /** |
| 86 | + * @description Initializes the contract by setting the initial owner via `ownerId` |
| 87 | + * and storing the `instanceSalt` that acts as a privacy additive for preventing |
| 88 | + * duplicate commitments among other contracts implementing ZOwnablePK. |
| 89 | + * |
| 90 | + * @warning The `ownerId` must be calculated prior to contract deployment using the SHA256 hashing algorithm. |
| 91 | + * Using any other algorithm will result in a permanent loss of contract access. |
| 92 | + * |
| 93 | + * @circuitInfo k=14, rows=14933 |
| 94 | + * |
| 95 | + * Requirements: |
| 96 | + * |
| 97 | + * - Contract is not initialized. |
| 98 | + * - `ownerId` is not zero. |
| 99 | + * |
| 100 | + * @param {Bytes<32>} ownerId - The owner's unique identifier SHA256(pk, nonce). |
| 101 | + * @param {Bytes<32>} instanceSalt - Contract salt to prevent duplicate commitments if |
| 102 | + * users reuse their PK and secretNonce witness (not recommended). |
| 103 | + * @returns {[]} Empty tuple. |
| 104 | + */ |
| 105 | + export circuit initialize(ownerId: Bytes<32>, instanceSalt: Bytes<32>): [] { |
| 106 | + Initializable_initialize(); |
| 107 | + |
| 108 | + assert(ownerId != default<Bytes<32>>, "ZOwnablePK: invalid id"); |
| 109 | + _instanceSalt = disclose(instanceSalt); |
| 110 | + _transferOwnership(ownerId); |
| 111 | + } |
| 112 | + |
| 113 | + /** |
| 114 | + * @description Returns the current commitment representing the contract owner. |
| 115 | + * The full commitment is: `SHA256(SHA256(pk, nonce), instanceSalt, counter, domain)`. |
| 116 | + * |
| 117 | + * @circuitInfo k=10, rows=57 |
| 118 | + * |
| 119 | + * Requirements: |
| 120 | + * |
| 121 | + * - Contract is initialized. |
| 122 | + * |
| 123 | + * @returns {Bytes<32>} The current owner's commitment. |
| 124 | + */ |
| 125 | + export circuit owner(): Bytes<32> { |
| 126 | + Initializable_assertInitialized(); |
| 127 | + return _ownerCommitment; |
| 128 | + } |
| 129 | + |
| 130 | + /** |
| 131 | + * @description Transfers ownership to `newOwnerId`. |
| 132 | + * `newOwnerId` must be precalculated and given to the current owner off chain. |
| 133 | + * |
| 134 | + * @circuitInfo k=16, rows=39240 |
| 135 | + * |
| 136 | + * Requirements: |
| 137 | + * |
| 138 | + * - Contract is initialized. |
| 139 | + * - Caller is the the current owner. |
| 140 | + * - `newOwnerId` is not an empty array. |
| 141 | + * |
| 142 | + * @param {Bytes<32>} newOwnerId - The new owner's unique identifier (`SHA256(pk, nonce)`). |
| 143 | + * @returns {[]} Empty tuple. |
| 144 | + */ |
| 145 | + export circuit transferOwnership(newOwnerId: Bytes<32>): [] { |
| 146 | + Initializable_assertInitialized(); |
| 147 | + |
| 148 | + assertOnlyOwner(); |
| 149 | + assert(newOwnerId != default<Bytes<32>>, "ZOwnablePK: invalid id"); |
| 150 | + _transferOwnership(newOwnerId); |
| 151 | + } |
| 152 | + |
| 153 | + /** |
| 154 | + * @description Leaves the contract without an owner. |
| 155 | + * It will not be possible to call `assertOnlyOnwer` circuits anymore. |
| 156 | + * Can only be called by the current owner. |
| 157 | + * |
| 158 | + * @circuitInfo k=15, rows=24442 |
| 159 | + * |
| 160 | + * Requirements: |
| 161 | + * |
| 162 | + * - Contract is initialized. |
| 163 | + * - Caller is the the current owner. |
| 164 | + * |
| 165 | + * @returns {[]} Empty tuple. |
| 166 | + */ |
| 167 | + export circuit renounceOwnership(): [] { |
| 168 | + Initializable_assertInitialized(); |
| 169 | + |
| 170 | + assertOnlyOwner(); |
| 171 | + _ownerCommitment.resetToDefault(); |
| 172 | + } |
| 173 | + |
| 174 | + /** |
| 175 | + * @description Throws if called by any account whose id hash `SHA256(pk, nonce)` does not match |
| 176 | + * the stored owner commitment. |
| 177 | + * Use this to only allow the owner to call specific circuits. |
| 178 | + * |
| 179 | + * @circuitInfo k=15, rows=24437 |
| 180 | + * |
| 181 | + * Requirements: |
| 182 | + * |
| 183 | + * - Contract is initialized. |
| 184 | + * - Caller's id (`SHA256(pk, nonce)`) when used in `_computeOwnerCommitment` equals |
| 185 | + * the stored `_ownerCommitment`, thus verifying themselves as the owner. |
| 186 | + * |
| 187 | + * @returns {[]} Empty tuple. |
| 188 | + */ |
| 189 | + export circuit assertOnlyOwner(): [] { |
| 190 | + Initializable_assertInitialized(); |
| 191 | + |
| 192 | + const nonce = wit_secretNonce(); |
| 193 | + const callerAsEither = Either<ZswapCoinPublicKey, ContractAddress> { |
| 194 | + is_left: true, |
| 195 | + left: ownPublicKey(), |
| 196 | + right: ContractAddress { bytes: pad(32, "") } |
| 197 | + }; |
| 198 | + const id = _computeOwnerId(callerAsEither, nonce); |
| 199 | + assert(_ownerCommitment == _computeOwnerCommitment(id, _counter), "ZOwnablePK: caller is not the owner"); |
| 200 | + } |
| 201 | + |
| 202 | + /** |
| 203 | + * @description Computes the owner commitment from the given `id` and `counter`. |
| 204 | + * |
| 205 | + * ## Owner ID (`id`) |
| 206 | + * The `id` is expected to be computed off-chain as: |
| 207 | + * `id = SHA256(pk, nonce)` |
| 208 | + * |
| 209 | + * - `pk`: The owner's public key. |
| 210 | + * - `nonce`: A secret nonce scoped to the instance, ideally rotated with each transfer. |
| 211 | + * |
| 212 | + * ## Commitment Derivation |
| 213 | + * `commitment = SHA256(id, instanceSalt, counter, domain)` |
| 214 | + * |
| 215 | + * - `id`: See above. |
| 216 | + * - `instanceSalt`: A unique per-deployment salt, stored during initialization. |
| 217 | + * This prevents commitment collisions across deployments. |
| 218 | + * - `counter`: Incremented with each ownership transfer, ensuring uniqueness |
| 219 | + * even with repeated `id` values. Cast to `Field` then `Bytes<32>` for hashing. |
| 220 | + * - `domain`: Domain separator `"ZOwnablePK:shield:"` (padded to 32 bytes) to prevent |
| 221 | + * hash collisions when extending the module or using similar commitment schemes. |
| 222 | + * |
| 223 | + * @circuitInfo k=14, rows=14853 |
| 224 | + * |
| 225 | + * Requirements: |
| 226 | + * |
| 227 | + * - Contract is initialized. |
| 228 | + * |
| 229 | + * @param {Bytes<32>} id - The unique identifier of the owner calculated by `SHA256(pk, nonce)`. |
| 230 | + * @param {Uint<64>} counter - The current counter or round. This increments by `1` |
| 231 | + * after every transfer to prevent duplicate commitments given the same `id`. |
| 232 | + * @returns {Bytes<32>} The commitment derived from `id` and `counter`. |
| 233 | + */ |
| 234 | + export circuit _computeOwnerCommitment( |
| 235 | + id: Bytes<32>, |
| 236 | + counter: Uint<64>, |
| 237 | + ): Bytes<32> { |
| 238 | + Initializable_assertInitialized(); |
| 239 | + return persistentHash<Vector<4, Bytes<32>>>( |
| 240 | + [ |
| 241 | + id, |
| 242 | + _instanceSalt, |
| 243 | + counter as Field as Bytes<32>, |
| 244 | + pad(32, "ZOwnablePK:shield:") |
| 245 | + ] |
| 246 | + ); |
| 247 | + } |
| 248 | + |
| 249 | + /** |
| 250 | + * @description Computes the unique identifier (`id`) of the owner from their |
| 251 | + * public key and a secret nonce. |
| 252 | + * |
| 253 | + * ## ID Derivation |
| 254 | + * `id = SHA256(pk, nonce)` |
| 255 | + * |
| 256 | + * - `pk`: The public key of the caller. This is passed explicitly to allow |
| 257 | + * for off-chain derivation, testing, or scenarios where the caller is |
| 258 | + * different from the subject of the computation. |
| 259 | + * We recommend using an Air-Gapped Public Key. |
| 260 | + * - `nonce`: A secret nonce tied to the identity. The generation strategy is |
| 261 | + * left to the user, offering different security/convenience trade-offs. |
| 262 | + * |
| 263 | + * The result is a 32-byte commitment that uniquely identifies the owner. |
| 264 | + * This value is later used in owner commitment hashing, |
| 265 | + * and acts as a privacy-preserving alternative to a raw public key. |
| 266 | + * |
| 267 | + * @notice This module allows ownership to be tied to an identity commitment derived |
| 268 | + * from a public key and secret nonce. |
| 269 | + * While typically used with user public keys, this mechanism may also |
| 270 | + * support contract addresses as identifiers in future contract-to-contract |
| 271 | + * interactions. Both are treated as 32-byte values (`Bytes<32>`). |
| 272 | + * |
| 273 | + * Requirements: |
| 274 | + * |
| 275 | + * - `pk` is not a ContractAddress. |
| 276 | + * |
| 277 | + * @param {Either<ZswapCoinPublicKey, ContractAddress>} pk - The public key of the identity being committed. |
| 278 | + * @param {Bytes<32>} nonce - A private nonce to scope the commitment. |
| 279 | + * @returns {Bytes<32>} The computed owner ID. |
| 280 | + */ |
| 281 | + export pure circuit _computeOwnerId( |
| 282 | + pk: Either<ZswapCoinPublicKey, ContractAddress>, |
| 283 | + nonce: Bytes<32> |
| 284 | + ): Bytes<32> { |
| 285 | + assert(pk.is_left, "ZOwnablePK: contract address owners are not yet supported"); |
| 286 | + |
| 287 | + return persistentHash<Vector<2, Bytes<32>>>([pk.left.bytes, nonce]); |
| 288 | + } |
| 289 | + |
| 290 | + /** |
| 291 | + * @description Transfers ownership to owner id `newOwnerId` without |
| 292 | + * enforcing permission checks on the caller. |
| 293 | + * |
| 294 | + * @circuitInfo k=14, rows=14823 |
| 295 | + * |
| 296 | + * Requirements: |
| 297 | + * |
| 298 | + * - Contract is initialized. |
| 299 | + * |
| 300 | + * @param {Bytes<32>} newOwnerId - The unique identifier of the new owner |
| 301 | + * calculated by `SHA256(pk, nonce)`. |
| 302 | + * @returns {[]} Empty tuple. |
| 303 | + */ |
| 304 | + export circuit _transferOwnership(newOwnerId: Bytes<32>): [] { |
| 305 | + Initializable_assertInitialized(); |
| 306 | + |
| 307 | + _counter.increment(1); |
| 308 | + _ownerCommitment = _computeOwnerCommitment(disclose(newOwnerId), _counter); |
| 309 | + } |
| 310 | +} |
0 commit comments