Skip to main content

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}