Skip to content

Commit 78d664c

Browse files
authored
Merge pull request #1 from Olanetsoft/fix/access-control-ownpublickey-auth-antipattern
fix(access-control): replace ownPublicKey() auth with witness-derived keypair pattern
2 parents 1d7029b + 9133b2a commit 78d664c

1 file changed

Lines changed: 192 additions & 96 deletions

File tree

basics/access-control.mdx

Lines changed: 192 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,170 +1,266 @@
11
---
22
title: "Access Control"
33
sidebarTitle: "Access Control"
4-
description: "Restrict function access to authorized accounts."
4+
description: "Restrict circuit access using witness-derived keypairs."
55
"og:title": "Access Control - Compact by Example"
6-
"og:description": "Owner and role-based access control patterns for smart contracts"
6+
"og:description": "Witness-derived keypair authorization for Compact smart contracts"
77
"twitter:card": "summary_large_image"
88
"twitter:title": "Access Control - Compact by Example"
9-
"twitter:description": "Owner and role-based access control patterns for smart contracts"
10-
---le: "Access Control"
11-
sidebarTitle: "Access Control"
12-
description: "Restrict function access to authorized accounts."
13-
"og:title": "Access Control - Compact by Example"
14-
"og:description": "Owner-only and role-based access control patterns"
9+
"twitter:description": "Witness-derived keypair authorization for Compact smart contracts"
1510
---
1611

12+
<Warning>
13+
**Security warning — `ownPublicKey()` does not authorize the caller.**
14+
15+
`ownPublicKey()` returns a value the prover knows, not a proof of key
16+
ownership. Any value stored on the public ledger can be replayed by anyone
17+
reading the chain. Do not use `ownPublicKey()` for authorization checks
18+
(`assert(ownPublicKey() == storedAdmin)`). Use it only as an identifier for
19+
the operation's target (e.g. "mint to this caller"). For authorization, see
20+
[Owner-Only Pattern](#owner-only-pattern) below.
21+
</Warning>
22+
1723
## The Pattern
1824

1925
<Accordion title="View Code">
2026

2127
```compact
22-
pragma language_version >= 0.17.0;
28+
pragma language_version >= 0.22.0;
2329
2430
import CompactStandardLibrary;
2531
26-
// Store contract owner
27-
export ledger owner: Either<ZswapCoinPublicKey, ContractAddress>;
32+
struct AdminSecretKey { bytes: Bytes<32>; }
33+
struct AdminPublicKey { bytes: Bytes<32>; }
34+
35+
// The ledger stores only the derived public key (a hash of the secret),
36+
// never the secret itself.
37+
export ledger contractAdmin: AdminPublicKey;
2838
29-
// Initialize owner in constructor
30-
circuit constructor(): [] {
31-
owner = disclose(left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()));
39+
// The deployer's DApp generates this secret and keeps it in private state.
40+
witness getAdminSecret(): AdminSecretKey;
41+
42+
constructor() {
43+
contractAdmin = disclose(deriveAdminPublicKey(getAdminSecret()));
3244
}
3345
34-
// Owner-only function
35-
export circuit restrictedFunction(): [] {
36-
const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
37-
assert(caller == owner, "Only owner can call");
46+
export circuit deriveAdminPublicKey(sk: AdminSecretKey): AdminPublicKey {
47+
return AdminPublicKey {
48+
bytes: persistentHash<Vector<2, Bytes<32>>>([
49+
pad(32, "myapp:admin:pk:v1"),
50+
sk.bytes
51+
])
52+
};
53+
}
3854
39-
// Protected logic here
55+
export circuit privilegedAction(): [] {
56+
// To pass, the caller must supply a witness `sk` such that
57+
// H("myapp:admin:pk:v1" || sk) == contractAdmin.
58+
assert(
59+
contractAdmin == deriveAdminPublicKey(getAdminSecret()),
60+
"Not authorized."
61+
);
62+
// ... privileged logic
4063
}
4164
42-
// Get caller address
43-
export circuit getCaller(): Either<ZswapCoinPublicKey, ContractAddress> {
44-
return left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
65+
// Rotate the admin key without ever transmitting a private key.
66+
// The new admin generates their secret locally and shares only the
67+
// derived public key with the current admin.
68+
export circuit rotateAdmin(newAdmin: AdminPublicKey): [] {
69+
assert(
70+
contractAdmin == deriveAdminPublicKey(getAdminSecret()),
71+
"Not authorized to rotate admin."
72+
);
73+
contractAdmin = disclose(newAdmin);
4574
}
4675
```
4776

4877
</Accordion>
4978

5079
## Owner-Only Pattern
5180

52-
### Set Owner in Constructor
81+
The contract stores `H(domain || secret)` on the ledger, never the secret
82+
itself. The admin's DApp holds the secret in private state and re-derives
83+
the public key on every call. The on-chain value gives an attacker only the
84+
hash; hash preimage resistance means they cannot forge a matching secret.
85+
86+
### Store the Admin Public Key
5387

5488
```compact
55-
export ledger owner: Either<ZswapCoinPublicKey, ContractAddress>;
89+
struct AdminSecretKey { bytes: Bytes<32>; }
90+
struct AdminPublicKey { bytes: Bytes<32>; }
91+
92+
export ledger contractAdmin: AdminPublicKey;
5693
57-
circuit constructor(): [] {
58-
owner = disclose(left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey()));
94+
witness getAdminSecret(): AdminSecretKey;
95+
96+
constructor() {
97+
contractAdmin = disclose(deriveAdminPublicKey(getAdminSecret()));
98+
}
99+
100+
export circuit deriveAdminPublicKey(sk: AdminSecretKey): AdminPublicKey {
101+
return AdminPublicKey {
102+
bytes: persistentHash<Vector<2, Bytes<32>>>([
103+
pad(32, "myapp:admin:pk:v1"),
104+
sk.bytes
105+
])
106+
};
59107
}
60108
```
61109

62-
The deployer automatically becomes the owner.
110+
The domain string `"myapp:admin:pk:v1"` binds the public key to this
111+
contract. The same secret will not derive the same public key under a
112+
different domain, so an admin key cannot be replayed across contracts.
63113

64-
### Restrict Function Access
114+
### Authorization Check
65115

66116
```compact
67-
export circuit mint(
68-
account: Either<ZswapCoinPublicKey, ContractAddress>,
69-
amount: Uint<128>
70-
): [] {
71-
// Only owner can mint
72-
const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
73-
assert(caller == owner, "Only owner can mint");
74-
75-
_mint(account, amount);
117+
export circuit privilegedAction(): [] {
118+
assert(
119+
contractAdmin == deriveAdminPublicKey(getAdminSecret()),
120+
"Not authorized."
121+
);
122+
// ... privileged logic
76123
}
77124
```
78125

79-
Check caller matches owner before executing.
126+
The assertion succeeds only if the caller knows a secret whose hash matches
127+
the stored value. Reading `contractAdmin` off-chain gives an attacker the
128+
hash, not the preimage.
80129

81-
### Transfer Ownership
130+
### Rotate the Admin
82131

83132
```compact
84-
export circuit transferOwnership(
85-
newOwner: Either<ZswapCoinPublicKey, ContractAddress>
86-
): [] {
87-
const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
88-
assert(caller == owner, "Only owner can transfer");
89-
90-
owner = disclose(newOwner);
133+
export circuit rotateAdmin(newAdmin: AdminPublicKey): [] {
134+
assert(
135+
contractAdmin == deriveAdminPublicKey(getAdminSecret()),
136+
"Not authorized to rotate admin."
137+
);
138+
contractAdmin = disclose(newAdmin);
91139
}
92140
```
93141

94-
Allow current owner to transfer to new owner.
142+
The new admin generates their secret locally and shares only the derived
143+
public key with the current admin. Private keys never leave the holder's
144+
DApp.
95145

96-
## Caller Identification
146+
## Why This Works
97147

98-
### Get Caller Address
148+
The ledger stores `H(domain || secret)` — the hash of the admin's secret.
149+
To pass the assertion, the caller must provide a witness `sk` such that
150+
`H(domain || sk)` equals the stored value. The on-chain value is a hash,
151+
not the secret itself, so reading the ledger gives an attacker nothing they
152+
can replay. The domain string prevents the same secret from being valid
153+
across different contracts.
99154

100-
```compact
101-
// In any circuit
102-
const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
103-
```
155+
This is what Solidity gets implicitly from `msg.sender` and EVM signature
156+
verification. In Compact, you construct the check yourself.
104157

105-
`ownPublicKey()` returns the public key of whoever called the circuit.
158+
## Anti-Pattern: ownPublicKey() as Authorization
106159

107-
### Authorize Sender
160+
<Warning>
161+
**Do not use this pattern.** It is shown only to document what fails.
162+
</Warning>
108163

109164
```compact
110-
export circuit transfer(
111-
to: Either<ZswapCoinPublicKey, ContractAddress>,
112-
amount: Uint<128>
113-
): [] {
114-
// Sender is always the caller
115-
const from = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
116-
117-
// Transfer from caller's balance
118-
_transfer(from, to, amount);
165+
// DO NOT USE — this is broken
166+
export ledger admin: ZswapCoinPublicKey;
167+
168+
constructor() {
169+
admin = disclose(ownPublicKey());
170+
}
171+
172+
export circuit privileged(): [] {
173+
assert(ownPublicKey() == admin, "Only admin.");
119174
}
120175
```
121176

122-
The caller is automatically authorized to spend their own tokens.
177+
`ownPublicKey()` returns whatever value the prover claims to know. An
178+
attacker reads `admin` from the public ledger, supplies it as their own
179+
`ownPublicKey()`, and produces a mathematically valid proof. The proof
180+
correctly demonstrates "the prover knows a value equal to `admin`" — which
181+
anyone watching the chain does. The check provides no authorization.
123182

124-
## Role-Based Access
183+
## When to Use ownPublicKey()
125184

126-
### Multiple Roles
185+
`ownPublicKey()` is the correct primitive when you need a stable identifier
186+
for the caller as the **target** of an operation:
127187

128-
```compact
129-
export ledger owner: Either<ZswapCoinPublicKey, ContractAddress>;
130-
export ledger minter: Either<ZswapCoinPublicKey, ContractAddress>;
131-
export ledger burner: Either<ZswapCoinPublicKey, ContractAddress>;
132-
133-
// Owner can mint and burn
134-
export circuit mint(account: Either<ZswapCoinPublicKey, ContractAddress>, amount: Uint<128>): [] {
135-
const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
136-
assert(caller == owner || caller == minter, "Not authorized to mint");
137-
_mint(account, amount);
138-
}
188+
- "Mint a token to this caller": `mint(ownPublicKey(), tokenId)`
189+
- "Credit this caller's balance": `balances.insert(ownPublicKey(), ...)`
190+
- "Record this caller as the recipient": `recipient = disclose(ownPublicKey())`
139191

140-
export circuit burn(amount: Uint<128>): [] {
141-
const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
142-
assert(caller == owner || caller == burner, "Not authorized to burn");
143-
_burn(caller, amount);
144-
}
145-
```
192+
It identifies who the operation is for. It does not prove who is allowed
193+
to perform it. The moment you write `assert(ownPublicKey() == ...)`, you
194+
have crossed into authorization, and the pattern breaks.
146195

147-
Different roles for different operations.
196+
## Role-Based Access
148197

149-
### Admin Functions
198+
Multiple roles use the same keypair pattern with one ledger slot per role
199+
and a single witness that returns the caller's secret.
150200

151201
```compact
152-
export circuit setMinter(newMinter: Either<ZswapCoinPublicKey, ContractAddress>): [] {
153-
const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
154-
assert(caller == owner, "Only owner");
202+
struct SecretKey { bytes: Bytes<32>; }
203+
struct PublicKey { bytes: Bytes<32>; }
204+
205+
export ledger contractOwner: PublicKey;
206+
export ledger contractMinter: PublicKey;
155207
156-
minter = disclose(newMinter);
208+
witness getCallerSecret(): SecretKey;
209+
210+
constructor(initialMinter: PublicKey) {
211+
contractOwner = disclose(derivePublicKey(getCallerSecret()));
212+
contractMinter = disclose(initialMinter);
213+
}
214+
215+
export circuit derivePublicKey(sk: SecretKey): PublicKey {
216+
return PublicKey {
217+
bytes: persistentHash<Vector<2, Bytes<32>>>([
218+
pad(32, "myapp:role:v1"),
219+
sk.bytes
220+
])
221+
};
157222
}
158223
159-
export circuit setBurner(newBurner: Either<ZswapCoinPublicKey, ContractAddress>): [] {
160-
const caller = left<ZswapCoinPublicKey, ContractAddress>(ownPublicKey());
161-
assert(caller == owner, "Only owner");
224+
// Owner-only: rotate the minter slot
225+
export circuit setMinter(newMinter: PublicKey): [] {
226+
assert(
227+
contractOwner == derivePublicKey(getCallerSecret()),
228+
"Only owner can set minter."
229+
);
230+
contractMinter = disclose(newMinter);
231+
}
162232
163-
burner = disclose(newBurner);
233+
// Either role may call this
234+
export circuit mintingAction(): [] {
235+
const callerKey = disclose(derivePublicKey(getCallerSecret()));
236+
assert(
237+
callerKey == contractOwner || callerKey == contractMinter,
238+
"Not authorized to mint."
239+
);
240+
// ... minting logic
164241
}
165242
```
166243

167-
Owner manages role assignments.
244+
The `||` check would reveal which role matched, so `callerKey` is disclosed
245+
explicitly. That is safe: a derived public key equals the on-chain value
246+
only when the caller is already authorized, and the on-chain value is
247+
public anyway.
248+
249+
## Alternative Patterns
250+
251+
The keypair pattern above covers single-admin and role-based authorization.
252+
For other situations:
253+
254+
- **Signature verification against a stored verifying key** — when you need
255+
multi-sig, cold-key signing, or cross-contract authority. The contract
256+
stores a verifying key; privileged circuits accept a signature over the
257+
operation as a parameter.
258+
- **Commitment / nullifier patterns** — when you need anonymous user
259+
authorization with unlinkability across actions (anonymous voting,
260+
single-use credentials). The contract stores commitments and tracks
261+
nullifiers.
262+
263+
Both are documented elsewhere.
168264

169265
## What's Next
170266

@@ -173,6 +269,6 @@ Owner manages role assignments.
173269
Apply access control to minting
174270
</Card>
175271
<Card title="ERC20 Token" icon="coins" href="/applications/erc20-token">
176-
See access control in complete token
272+
See access control in a complete token
177273
</Card>
178274
</CardGroup>

0 commit comments

Comments
 (0)