Skip to main content

auths_keri/
state.rs

1//! Key state derived from replaying a KERI event log.
2//!
3//! The `KeyState` represents the current cryptographic state of a KERI
4//! identity after processing all events in its KEL. This is the "resolved"
5//! state used for signature verification and capability checking.
6
7use serde::{Deserialize, Serialize};
8
9use crate::types::{CesrKey, ConfigTrait, Prefix, Said, Threshold};
10
11/// Current key state derived from replaying a KEL.
12///
13/// This struct captures the complete state of a KERI identity at a given
14/// point in its event log. It is computed by walking the KEL from inception
15/// to the latest event.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18pub struct KeyState {
19    /// The KERI identifier prefix (used in `did:keri:<prefix>`)
20    pub prefix: Prefix,
21
22    /// Current signing key(s), CESR-encoded.
23    pub current_keys: Vec<CesrKey>,
24
25    /// Next key commitment(s) for pre-rotation (Blake3 digests).
26    pub next_commitment: Vec<Said>,
27
28    /// Current sequence number (0 for inception, increments with each event)
29    pub sequence: u128,
30
31    /// SAID of the last processed event
32    pub last_event_said: Said,
33
34    /// Whether this identity has been abandoned (empty next commitment in rotation)
35    pub is_abandoned: bool,
36    /// Current signing threshold
37    pub threshold: Threshold,
38    /// Next signing threshold (committed)
39    pub next_threshold: Threshold,
40    /// Current backer/witness list
41    #[serde(default)]
42    pub backers: Vec<Prefix>,
43    /// Current backer threshold
44    #[serde(default)]
45    pub backer_threshold: Threshold,
46    /// Configuration traits from inception (and rotation for RB/NRB)
47    #[serde(default)]
48    pub config_traits: Vec<ConfigTrait>,
49    /// Whether this identity is non-transferable (inception `n` was empty)
50    #[serde(default)]
51    pub is_non_transferable: bool,
52    /// Delegator AID (if this is a delegated identity)
53    #[serde(default)]
54    pub delegator: Option<Prefix>,
55    /// Sequence number of the last establishment event (ICP or ROT).
56    /// Used to locate the pre-committed next key in the keychain.
57    /// IXN events do not change this value.
58    #[serde(default)]
59    pub last_establishment_sequence: u128,
60}
61
62/// Three-state anchor verification result.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64pub enum AnchorStatus {
65    /// Seal found in KEL and resolves to a matching attestation blob.
66    Anchored,
67    /// Seal found in KEL but the referenced blob is missing or cannot be resolved.
68    Unverified,
69    /// No matching seal exists in the KEL.
70    NotAnchored,
71}
72
73impl KeyState {
74    /// Create initial state from an inception event.
75    ///
76    /// Args:
77    /// * `prefix` - The KERI identifier (same as inception SAID)
78    /// * `keys` - The initial signing key(s)
79    /// * `next` - The next-key commitment(s)
80    /// * `threshold` - Initial signing threshold
81    /// * `next_threshold` - Committed next signing threshold
82    /// * `said` - The inception event SAID
83    /// * `backers` - Initial witness/backer list
84    /// * `backer_threshold` - Witness/backer threshold
85    /// * `config_traits` - Configuration traits from inception
86    #[allow(clippy::too_many_arguments)]
87    pub fn from_inception(
88        prefix: Prefix,
89        keys: Vec<CesrKey>,
90        next: Vec<Said>,
91        threshold: Threshold,
92        next_threshold: Threshold,
93        said: Said,
94        backers: Vec<Prefix>,
95        backer_threshold: Threshold,
96        config_traits: Vec<ConfigTrait>,
97    ) -> Self {
98        let is_non_transferable = next.is_empty();
99        Self {
100            prefix,
101            current_keys: keys,
102            next_commitment: next.clone(),
103            sequence: 0,
104            last_event_said: said,
105            // A non-rotating inception (empty `n`) is non-transferable, not
106            // abandoned. Abandonment is a post-inception state reached only by
107            // a rotation to an empty next commitment (see `apply_rotation`).
108            is_abandoned: false,
109            threshold,
110            next_threshold,
111            backers,
112            backer_threshold,
113            config_traits,
114            is_non_transferable,
115            delegator: None,
116            last_establishment_sequence: 0,
117        }
118    }
119
120    /// Apply a rotation event to update state.
121    ///
122    /// This should only be called after verifying:
123    /// 1. The new key matches the previous next_commitment
124    /// 2. The event's previous SAID matches last_event_said
125    /// 3. The sequence is exactly last_sequence + 1
126    #[allow(clippy::too_many_arguments)]
127    pub fn apply_rotation(
128        &mut self,
129        new_keys: Vec<CesrKey>,
130        new_next: Vec<Said>,
131        threshold: Threshold,
132        next_threshold: Threshold,
133        sequence: u128,
134        said: Said,
135        backers_to_remove: &[Prefix],
136        backers_to_add: &[Prefix],
137        backer_threshold: Threshold,
138        config_traits: Vec<ConfigTrait>,
139    ) {
140        self.current_keys = new_keys;
141        self.next_commitment = new_next.clone();
142        self.threshold = threshold;
143        self.next_threshold = next_threshold;
144        self.sequence = sequence;
145        self.last_event_said = said;
146        self.is_abandoned = new_next.is_empty();
147
148        // Apply backer deltas: remove first, then add
149        self.backers.retain(|b| !backers_to_remove.contains(b));
150        self.backers.extend(backers_to_add.iter().cloned());
151        self.backer_threshold = backer_threshold;
152
153        // Update config traits (RB/NRB can change in rotation)
154        if !config_traits.is_empty() {
155            self.config_traits = config_traits;
156        }
157
158        self.last_establishment_sequence = sequence;
159    }
160
161    /// Apply an interaction event (updates sequence and SAID only).
162    ///
163    /// Interaction events anchor data but don't change keys.
164    pub fn apply_interaction(&mut self, sequence: u128, said: Said) {
165        self.sequence = sequence;
166        self.last_event_said = said;
167    }
168
169    /// Get the current signing key (first key for single-sig).
170    pub fn current_key(&self) -> Option<&CesrKey> {
171        self.current_keys.first()
172    }
173
174    /// Check if key can be rotated.
175    ///
176    /// Returns `false` if the identity has been abandoned (empty next commitment).
177    pub fn can_rotate(&self) -> bool {
178        !self.is_abandoned && !self.next_commitment.is_empty()
179    }
180
181    /// Check if this identity can emit interaction (ixn) events.
182    ///
183    /// Returns `false` if the identity is non-transferable (empty `n[]` at inception)
184    /// or establishment-only (`"EO"` in `c[]`). Both conditions prohibit ixn events
185    /// per KERI spec.
186    pub fn can_emit_ixn(&self) -> bool {
187        !self.is_non_transferable
188            && !self
189                .config_traits
190                .contains(&crate::types::ConfigTrait::EstablishmentOnly)
191    }
192
193    /// Get the DID for this identity.
194    pub fn did(&self) -> String {
195        format!("did:keri:{}", self.prefix.as_str())
196    }
197}
198
199#[cfg(test)]
200#[allow(clippy::unwrap_used)]
201mod tests {
202    use super::*;
203
204    fn make_key(s: &str) -> CesrKey {
205        CesrKey::new_unchecked(s.to_string())
206    }
207
208    fn make_state() -> KeyState {
209        KeyState::from_inception(
210            Prefix::new_unchecked("EPrefix".to_string()),
211            vec![make_key("DKey1")],
212            vec![Said::new_unchecked("ENext1".to_string())],
213            Threshold::Simple(1),
214            Threshold::Simple(1),
215            Said::new_unchecked("ESAID".to_string()),
216            vec![],
217            Threshold::Simple(0),
218            vec![],
219        )
220    }
221
222    #[test]
223    fn key_state_from_inception() {
224        let state = make_state();
225        assert_eq!(state.sequence, 0);
226        assert!(!state.is_abandoned);
227        assert!(state.can_rotate());
228        assert_eq!(state.current_key().map(|k| k.as_str()), Some("DKey1"));
229        assert_eq!(state.did(), "did:keri:EPrefix");
230    }
231
232    #[test]
233    fn non_transferable_inception_is_not_abandoned() {
234        // An inception with an empty next commitment is born non-transferable,
235        // which is distinct from being abandoned (a post-rotation state).
236        let state = KeyState::from_inception(
237            Prefix::new_unchecked("EPrefix".to_string()),
238            vec![make_key("DKey1")],
239            vec![],
240            Threshold::Simple(1),
241            Threshold::Simple(0),
242            Said::new_unchecked("ESAID".to_string()),
243            vec![],
244            Threshold::Simple(0),
245            vec![],
246        );
247        assert!(state.is_non_transferable);
248        assert!(!state.is_abandoned);
249        assert!(!state.can_rotate());
250    }
251
252    #[test]
253    fn key_state_apply_rotation() {
254        let mut state = make_state();
255
256        state.apply_rotation(
257            vec![make_key("DKey2")],
258            vec![Said::new_unchecked("ENext2".to_string())],
259            Threshold::Simple(1),
260            Threshold::Simple(1),
261            1,
262            Said::new_unchecked("ESAID2".to_string()),
263            &[],
264            &[],
265            Threshold::Simple(0),
266            vec![],
267        );
268
269        assert_eq!(state.sequence, 1);
270        assert_eq!(state.current_keys[0].as_str(), "DKey2");
271        assert_eq!(state.next_commitment[0], "ENext2");
272        assert_eq!(state.last_event_said, "ESAID2");
273        assert!(state.can_rotate());
274    }
275
276    #[test]
277    fn key_state_apply_interaction() {
278        let mut state = make_state();
279        state.apply_interaction(1, Said::new_unchecked("ESAID_IXN".to_string()));
280
281        assert_eq!(state.sequence, 1);
282        assert_eq!(state.current_keys[0].as_str(), "DKey1");
283        assert_eq!(state.last_event_said, "ESAID_IXN");
284    }
285
286    #[test]
287    fn abandoned_identity_cannot_rotate() {
288        // Abandonment is reached by rotating to an empty next commitment,
289        // not at inception (a non-transferable inception is a separate state —
290        // see `non_transferable_inception_is_not_abandoned`).
291        let mut state = make_state();
292        assert!(!state.is_abandoned);
293
294        state.apply_rotation(
295            vec![make_key("DKey2")],
296            vec![],
297            Threshold::Simple(1),
298            Threshold::Simple(0),
299            1,
300            Said::new_unchecked("ESAID_ROT".to_string()),
301            &[],
302            &[],
303            Threshold::Simple(0),
304            vec![],
305        );
306        assert!(state.is_abandoned);
307        assert!(!state.can_rotate());
308    }
309
310    #[test]
311    fn key_state_serializes() {
312        let state = make_state();
313        let json = serde_json::to_string(&state).unwrap();
314        let parsed: KeyState = serde_json::from_str(&json).unwrap();
315        assert_eq!(state, parsed);
316    }
317
318    #[test]
319    fn rotation_applies_backer_deltas() {
320        let mut state = KeyState::from_inception(
321            Prefix::new_unchecked("EPrefix".to_string()),
322            vec![make_key("DKey1")],
323            vec![Said::new_unchecked("ENext1".to_string())],
324            Threshold::Simple(1),
325            Threshold::Simple(1),
326            Said::new_unchecked("ESAID".to_string()),
327            vec![
328                Prefix::new_unchecked("DWit1".to_string()),
329                Prefix::new_unchecked("DWit2".to_string()),
330            ],
331            Threshold::Simple(2),
332            vec![],
333        );
334
335        state.apply_rotation(
336            vec![make_key("DKey2")],
337            vec![Said::new_unchecked("ENext2".to_string())],
338            Threshold::Simple(1),
339            Threshold::Simple(1),
340            1,
341            Said::new_unchecked("ESAID2".to_string()),
342            &[Prefix::new_unchecked("DWit1".to_string())],
343            &[Prefix::new_unchecked("DWit3".to_string())],
344            Threshold::Simple(2),
345            vec![],
346        );
347
348        assert_eq!(state.backers.len(), 2);
349        assert_eq!(state.backers[0].as_str(), "DWit2");
350        assert_eq!(state.backers[1].as_str(), "DWit3");
351    }
352
353    #[test]
354    fn transferable_identity_can_emit_ixn() {
355        let state = make_state();
356        assert!(state.can_emit_ixn());
357    }
358
359    #[test]
360    fn non_transferable_identity_cannot_emit_ixn() {
361        let state = KeyState::from_inception(
362            Prefix::new_unchecked("EPrefix".to_string()),
363            vec![make_key("DKey1")],
364            vec![],
365            Threshold::Simple(1),
366            Threshold::Simple(0),
367            Said::new_unchecked("ESAID".to_string()),
368            vec![],
369            Threshold::Simple(0),
370            vec![],
371        );
372        assert!(state.is_non_transferable);
373        assert!(!state.can_emit_ixn());
374    }
375
376    #[test]
377    fn establishment_only_identity_cannot_emit_ixn() {
378        let state = KeyState::from_inception(
379            Prefix::new_unchecked("EPrefix".to_string()),
380            vec![make_key("DKey1")],
381            vec![Said::new_unchecked("ENext1".to_string())],
382            Threshold::Simple(1),
383            Threshold::Simple(1),
384            Said::new_unchecked("ESAID".to_string()),
385            vec![],
386            Threshold::Simple(0),
387            vec![ConfigTrait::EstablishmentOnly],
388        );
389        assert!(!state.can_emit_ixn());
390    }
391}