Skip to content

Commit eb3d4bc

Browse files
rebase
1 parent 5dd9470 commit eb3d4bc

16 files changed

Lines changed: 2156 additions & 29 deletions
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
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+
}

contracts/src/access/test/Ownable.test.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@ import { beforeEach, describe, expect, it } from 'vitest';
33
import { OwnableSimulator } from './simulators/OwnableSimulator.js';
44
import * as utils from './utils/address.js';
55

6-
// Callers
7-
const OWNER = utils.toHexPadded('OWNER');
8-
const NEW_OWNER = utils.toHexPadded('NEW_OWNER');
9-
const UNAUTHORIZED = utils.toHexPadded('UNAUTHORIZED');
10-
11-
// Encoded PK/Addresses
12-
const Z_OWNER = utils.createEitherTestUser('OWNER');
13-
const Z_NEW_OWNER = utils.createEitherTestUser('NEW_OWNER');
6+
// PKs
7+
const [OWNER, Z_OWNER] = utils.generateEitherPubKeyPair('OWNER');
8+
const [NEW_OWNER, Z_NEW_OWNER] = utils.generateEitherPubKeyPair('NEW_OWNER');
9+
const [UNAUTHORIZED, _] = utils.generateEitherPubKeyPair('UNAUTHORIZED');
10+
11+
// Encoded contract addresses
1412
const Z_OWNER_CONTRACT =
1513
utils.createEitherTestContractAddress('OWNER_CONTRACT');
1614
const Z_RECIPIENT_CONTRACT =

0 commit comments

Comments
 (0)