dig_identity/authority.rs
1//! The UPDATE-AUTHORITY predicate for a profile's DataLayer store — the fail-closed guard on the
2//! profile-update path (#1361 / #908).
3//!
4//! A profile's SMT root can only ADVANCE by spending the store singleton to recreate it with a new
5//! root — a spend the chain accepts ONLY from an authorized party. Two forms of authority let a
6//! caller land that advance:
7//!
8//! 1. **Owner** — the caller's puzzle satisfies the store singleton's CURRENT owner puzzle hash.
9//! 2. **Delegate** — the caller holds a CURRENTLY-VALID CHIP-0035 writer/admin delegation on the
10//! store (not revoked, not past its expiry height).
11//!
12//! An `Oracle` delegation ([`DelegationKind::Oracle`]) grants a READ-FEE right, **NOT** write authority, so it
13//! NEVER authorizes an update. That distinction is the security property this module exists to hold.
14//!
15//! # This module is the DECISION only
16//!
17//! dig-identity is a level-00 crate: it may depend on no other DIG crate, and in particular not on
18//! `dig-store`. So the two chain-touching halves of the update path live one level up (in
19//! `dig-social-profile` today):
20//!
21//! * the **chain-reading seam** that resolves the owner puzzle hash, the recorded delegations, and
22//! the current height from chain, and
23//! * the **spend builder** that turns an authorized advance into an unsigned DataLayer update spend.
24//!
25//! What lives HERE is the pure predicate [`StoreUpdateAuthority::authorizes`], which decides the two
26//! forms of authority over already-resolved chain facts. Keeping the decision separate from the
27//! source mirrors the crate's trust model ([`crate::resolve`]): a source reports chain FACTS only;
28//! the DECISION lives in the crate, is total, and fails closed.
29
30use chia_protocol::Bytes32;
31
32/// The kind of a CHIP-0035 delegation, which determines whether it grants WRITE authority.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum DelegationKind {
35 /// A writer may advance the store root — grants update authority.
36 Writer,
37 /// An admin may advance the store root and manage delegations — grants update authority.
38 Admin,
39 /// An oracle may collect the read fee — it does NOT grant update authority.
40 Oracle,
41}
42
43impl DelegationKind {
44 /// Whether this delegation kind grants authority to advance (update) the store root.
45 ///
46 /// Only [`Writer`](Self::Writer) and [`Admin`](Self::Admin) do; [`Oracle`](Self::Oracle) is a
47 /// read-fee right and never grants write authority.
48 #[must_use]
49 pub fn grants_update(self) -> bool {
50 matches!(self, DelegationKind::Writer | DelegationKind::Admin)
51 }
52}
53
54/// A CHIP-0035 delegation recorded on the store singleton, as read from current chain state.
55///
56/// A delegation authorizes an update ONLY when it is a write-granting kind ([`DelegationKind::Writer`]
57/// / [`DelegationKind::Admin`]), is NOT revoked, and has NOT expired (see
58/// [`StoreUpdateAuthority::authorizes`]).
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct WriterDelegation {
61 /// The delegate's puzzle hash — the party this delegation authorizes.
62 pub delegate_puzzle_hash: Bytes32,
63 /// The delegation kind (writer / admin grant update authority; oracle does not).
64 pub kind: DelegationKind,
65 /// Whether the delegation has been revoked on-chain (a revocation spend removed it). A revoked
66 /// delegation NEVER authorizes.
67 pub revoked: bool,
68 /// The block height at/after which the delegation is expired, if it carries an expiry. `None` means
69 /// it never expires; `Some(h)` means it is invalid once `current_height >= h`.
70 pub expires_at_height: Option<u32>,
71}
72
73impl WriterDelegation {
74 /// Whether this delegation authorizes an update for `caller_puzzle_hash` at `current_height`.
75 ///
76 /// True iff the delegate matches, the kind grants update authority, it is not revoked, and (if it
77 /// has an expiry) the current height is strictly before it. Every failing condition fails CLOSED.
78 #[must_use]
79 fn authorizes(&self, caller_puzzle_hash: Bytes32, current_height: u32) -> bool {
80 self.delegate_puzzle_hash == caller_puzzle_hash
81 && self.kind.grants_update()
82 && !self.revoked
83 && self
84 .expires_at_height
85 .map_or(true, |expiry| current_height < expiry)
86 }
87}
88
89/// The store singleton's CURRENT on-chain update authority — everything needed to DECIDE whether a
90/// caller may advance the store root, as resolved from chain by a higher-level chain source.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct StoreUpdateAuthority {
93 /// The store singleton's current owner puzzle hash. A caller whose puzzle hash equals this holds
94 /// full update authority.
95 pub owner_puzzle_hash: Bytes32,
96 /// The CHIP-0035 delegations currently recorded on the store (any kind; validity is decided by
97 /// [`Self::authorizes`], not by the source).
98 pub delegations: Vec<WriterDelegation>,
99 /// The current chain height, used to evaluate delegation expiry.
100 pub current_height: u32,
101}
102
103impl StoreUpdateAuthority {
104 /// Whether `caller_puzzle_hash` is authorized to advance (update) this store's root.
105 ///
106 /// True iff the caller is the current owner OR holds a currently-valid write-granting delegation.
107 /// Fails closed: an unknown caller, a revoked or expired delegation, or an oracle-only delegation
108 /// all return `false`.
109 ///
110 /// # Misuse boundary (MUST read)
111 ///
112 /// This is a **pre-flight predicate over chain facts the CALLER has already authenticated**. It
113 /// MUST NOT be evaluated over data supplied by the party being authorized: whoever controls
114 /// `owner_puzzle_hash` or `delegations` controls the answer outright. Resolve those fields from
115 /// chain (see [`crate::resolve`]'s trust model) before calling.
116 ///
117 /// A `true` here is NOT proof of on-chain authority — only the chain, accepting the spend, is
118 /// that. Use this to decide whether an update is worth ATTEMPTING (and to fail fast when it is
119 /// plainly not), never as the sole authorization for an action with consequences.
120 #[must_use]
121 pub fn authorizes(&self, caller_puzzle_hash: Bytes32) -> bool {
122 if caller_puzzle_hash == self.owner_puzzle_hash {
123 return true;
124 }
125 self.delegations
126 .iter()
127 .any(|delegation| delegation.authorizes(caller_puzzle_hash, self.current_height))
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 fn ph(byte: u8) -> Bytes32 {
136 Bytes32::new([byte; 32])
137 }
138
139 const OWNER: u8 = 0x01;
140 const WRITER: u8 = 0x02;
141 const STRANGER: u8 = 0x03;
142
143 fn delegation(ph_byte: u8, kind: DelegationKind) -> WriterDelegation {
144 WriterDelegation {
145 delegate_puzzle_hash: ph(ph_byte),
146 kind,
147 revoked: false,
148 expires_at_height: None,
149 }
150 }
151
152 fn authority(delegations: Vec<WriterDelegation>, current_height: u32) -> StoreUpdateAuthority {
153 StoreUpdateAuthority {
154 owner_puzzle_hash: ph(OWNER),
155 delegations,
156 current_height,
157 }
158 }
159
160 #[test]
161 fn owner_is_authorized() {
162 assert!(authority(vec![], 100).authorizes(ph(OWNER)));
163 }
164
165 #[test]
166 fn stranger_with_no_delegation_is_rejected() {
167 assert!(!authority(vec![], 100).authorizes(ph(STRANGER)));
168 }
169
170 #[test]
171 fn valid_writer_delegation_authorizes() {
172 let auth = authority(vec![delegation(WRITER, DelegationKind::Writer)], 100);
173 assert!(auth.authorizes(ph(WRITER)));
174 }
175
176 #[test]
177 fn valid_admin_delegation_authorizes() {
178 let auth = authority(vec![delegation(WRITER, DelegationKind::Admin)], 100);
179 assert!(auth.authorizes(ph(WRITER)));
180 }
181
182 #[test]
183 fn oracle_delegation_does_not_authorize_an_update() {
184 let auth = authority(vec![delegation(WRITER, DelegationKind::Oracle)], 100);
185 assert!(!auth.authorizes(ph(WRITER)));
186 }
187
188 #[test]
189 fn revoked_delegation_is_rejected() {
190 let mut revoked = delegation(WRITER, DelegationKind::Writer);
191 revoked.revoked = true;
192 assert!(!authority(vec![revoked], 100).authorizes(ph(WRITER)));
193 }
194
195 #[test]
196 fn expired_delegation_is_rejected() {
197 let mut expiring = delegation(WRITER, DelegationKind::Writer);
198 expiring.expires_at_height = Some(50);
199 // current height 100 is >= the expiry 50 -> expired.
200 assert!(!authority(vec![expiring], 100).authorizes(ph(WRITER)));
201 }
202
203 #[test]
204 fn delegation_valid_right_up_to_expiry_height() {
205 let mut expiring = delegation(WRITER, DelegationKind::Writer);
206 expiring.expires_at_height = Some(50);
207 // height 49 < 50 -> still valid; height 50 -> expired (invalid at/after).
208 assert!(authority(vec![expiring.clone()], 49).authorizes(ph(WRITER)));
209 assert!(!authority(vec![expiring], 50).authorizes(ph(WRITER)));
210 }
211
212 #[test]
213 fn delegation_for_a_different_caller_is_rejected() {
214 let auth = authority(vec![delegation(WRITER, DelegationKind::Writer)], 100);
215 assert!(!auth.authorizes(ph(STRANGER)));
216 }
217
218 #[test]
219 fn owner_is_authorized_even_when_a_delegation_is_revoked() {
220 let mut revoked = delegation(WRITER, DelegationKind::Writer);
221 revoked.revoked = true;
222 assert!(authority(vec![revoked], 100).authorizes(ph(OWNER)));
223 }
224
225 /// The owner short-circuit must hold against an EXPIRED delegation too, not only a revoked one.
226 ///
227 /// The revoked case above leaves the expiry arm of the short-circuit unexercised: an
228 /// implementation that returned early only when every delegation is revoked would still pass it.
229 #[test]
230 fn owner_is_authorized_even_when_a_delegation_is_expired() {
231 let mut expired = delegation(WRITER, DelegationKind::Writer);
232 expired.expires_at_height = Some(50);
233 assert!(authority(vec![expired], 100).authorizes(ph(OWNER)));
234 }
235
236 /// `expires_at_height = Some(0)` is a delegation that is NEVER valid: no height is `< 0`.
237 ///
238 /// It is the degenerate end of the strictly-less-than rule and is easy to special-case wrongly
239 /// (treating `Some(0)` as "no expiry"), which would fail OPEN. SPEC §8.3 states it normatively.
240 #[test]
241 fn delegation_expiring_at_height_zero_never_authorizes() {
242 let mut never_valid = delegation(WRITER, DelegationKind::Writer);
243 never_valid.expires_at_height = Some(0);
244 assert!(!authority(vec![never_valid.clone()], 0).authorizes(ph(WRITER)));
245 assert!(!authority(vec![never_valid], 100).authorizes(ph(WRITER)));
246 }
247
248 /// SPEC §8.3 requires SOME recorded delegation to authorize — an existential over the whole list.
249 ///
250 /// Every other test here carries zero or one delegation, so all of them pass an implementation
251 /// that inspects only the FIRST entry (`delegations.first().is_some_and(..)`). This fixture puts a
252 /// NON-authorizing delegation for the caller FIRST — same delegate, `Oracle`, i.e. a match on the
253 /// delegate that fails on kind — and the authorizing one SECOND, so a first-only implementation
254 /// answers `false` where the spec requires `true`.
255 #[test]
256 fn a_later_delegation_authorizes_when_an_earlier_one_does_not() {
257 let auth = authority(
258 vec![
259 delegation(WRITER, DelegationKind::Oracle),
260 delegation(WRITER, DelegationKind::Writer),
261 ],
262 100,
263 );
264 assert!(auth.authorizes(ph(WRITER)));
265 }
266
267 /// A puzzle hash differing from the owner's in its LAST byte only.
268 ///
269 /// Every other fixture is `[byte; 32]`, so any two differ in EVERY byte — under which a comparison
270 /// narrowed to one byte, or truncated to a prefix, still separates them. These twins do not.
271 fn twin(last_byte: u8) -> Bytes32 {
272 let mut bytes = [0xAA; 32];
273 bytes[31] = last_byte;
274 Bytes32::new(bytes)
275 }
276
277 #[test]
278 fn a_puzzle_hash_differing_only_in_its_last_byte_is_not_the_owner() {
279 let auth = StoreUpdateAuthority {
280 owner_puzzle_hash: twin(0xAA),
281 delegations: vec![],
282 current_height: 100,
283 };
284 assert!(auth.authorizes(twin(0xAA)));
285 assert!(!auth.authorizes(twin(0xAB)));
286 }
287
288 #[test]
289 fn a_delegation_does_not_authorize_a_twin_differing_only_in_its_last_byte() {
290 let auth = StoreUpdateAuthority {
291 owner_puzzle_hash: ph(OWNER),
292 delegations: vec![WriterDelegation {
293 delegate_puzzle_hash: twin(0xAA),
294 kind: DelegationKind::Writer,
295 revoked: false,
296 expires_at_height: None,
297 }],
298 current_height: 100,
299 };
300 assert!(auth.authorizes(twin(0xAA)));
301 assert!(!auth.authorizes(twin(0xAB)));
302 }
303}