Skip to main content

auths_id/keri/
rotation.rs

1//! KERI key rotation with pre-rotation commitment verification.
2//!
3//! Rotates keys by:
4//! 1. Verifying the next key matches the previous commitment
5//! 2. Generating a new next-key commitment
6//! 3. Creating and storing the rotation event
7
8use std::ops::ControlFlow;
9
10use auths_crypto::Pkcs8Der;
11use auths_keri::KeriPublicKey;
12
13use auths_core::crypto::said::{compute_next_commitment, verify_commitment};
14use auths_keri::compute_said;
15
16use super::event::{CesrKey, KeriSequence, Threshold, VersionString};
17use super::types::{Prefix, Said};
18use super::{Event, GitKel, KelError, RotEvent, ValidationError, validate_kel};
19use crate::storage::registry::backend::RegistryBackend;
20use crate::witness_config::WitnessConfig;
21
22use super::inception::generate_keypair_for_init;
23
24/// Errors from rotation and key state operations.
25#[derive(Debug, thiserror::Error)]
26#[non_exhaustive]
27pub enum RotationError {
28    #[error("KEL error: {0}")]
29    Kel(#[from] KelError),
30
31    #[error("Validation error: {0}")]
32    Validation(#[from] ValidationError),
33
34    #[error("Key generation failed: {0}")]
35    KeyGeneration(String),
36
37    #[error("Invalid key: {0}")]
38    InvalidKey(String),
39
40    #[error("Key commitment mismatch")]
41    CommitmentMismatch,
42
43    #[error("Identity is abandoned (empty next commitment)")]
44    IdentityAbandoned,
45
46    #[error("Serialization error: {0}")]
47    Serialization(String),
48
49    #[error("Storage error: {0}")]
50    Storage(#[from] crate::storage::registry::backend::RegistryError),
51
52    #[error("Rotation failed: {0}")]
53    RotationFailed(String),
54
55    #[error("Key not found: {0}")]
56    KeyNotFound(String),
57
58    #[error("Key decryption failed: {0}")]
59    KeyDecryptionFailed(String),
60}
61
62impl From<auths_core::error::AgentError> for RotationError {
63    fn from(e: auths_core::error::AgentError) -> Self {
64        RotationError::RotationFailed(e.to_string())
65    }
66}
67
68impl auths_core::error::AuthsErrorInfo for RotationError {
69    fn error_code(&self) -> &'static str {
70        match self {
71            Self::Kel(_) => "AUTHS-E4821",
72            Self::Validation(_) => "AUTHS-E4822",
73            Self::KeyGeneration(_) => "AUTHS-E4823",
74            Self::InvalidKey(_) => "AUTHS-E4824",
75            Self::CommitmentMismatch => "AUTHS-E4825",
76            Self::IdentityAbandoned => "AUTHS-E4826",
77            Self::Serialization(_) => "AUTHS-E4827",
78            Self::Storage(_) => "AUTHS-E4828",
79            Self::RotationFailed(_) => "AUTHS-E4829",
80            Self::KeyNotFound(_) => "AUTHS-E4830",
81            Self::KeyDecryptionFailed(_) => "AUTHS-E4831",
82        }
83    }
84
85    fn suggestion(&self) -> Option<&'static str> {
86        match self {
87            Self::CommitmentMismatch => Some(
88                "The provided key does not match the pre-committed next key. Use the key that was generated during initialization or the last rotation.",
89            ),
90            Self::IdentityAbandoned => Some(
91                "This identity has been permanently abandoned. Create a new identity with 'auths init'.",
92            ),
93            _ => None,
94        }
95    }
96}
97
98/// Result from a successful key rotation.
99#[derive(Debug, Clone)]
100pub struct RotationResult {
101    pub prefix: Prefix,
102    pub sequence: u128,
103    pub new_current_keypair_pkcs8: Pkcs8Der,
104    pub new_next_keypair_pkcs8: Pkcs8Der,
105    pub new_current_public_key: Vec<u8>,
106    pub new_next_public_key: Vec<u8>,
107}
108
109/// Detect the curve used by the identity from the current key state.
110fn detect_curve_from_state(state: &auths_keri::KeyState) -> auths_crypto::CurveType {
111    state
112        .current_keys
113        .first()
114        .and_then(|k| KeriPublicKey::parse(k.as_str()).ok())
115        .map(|kp| kp.curve())
116        .unwrap_or_default()
117}
118
119/// Parse a next-keypair PKCS8 into its public key bytes, seed, and CESR encoding (curve-agnostic).
120fn parse_next_key(
121    pkcs8: &[u8],
122) -> Result<(Vec<u8>, auths_crypto::TypedSeed, String), RotationError> {
123    let parsed = auths_crypto::parse_key_material(pkcs8)
124        .map_err(|e| RotationError::InvalidKey(e.to_string()))?;
125    let cesr_encoded =
126        auths_keri::KeriPublicKey::from_verkey_bytes(&parsed.public_key, parsed.seed.curve())
127            .and_then(|k| k.to_qb64())
128            .map_err(|e| RotationError::InvalidKey(e.to_string()))?;
129    Ok((parsed.public_key, parsed.seed, cesr_encoded))
130}
131
132/// Sign a rotation event with a typed seed (curve-agnostic).
133fn sign_rotation(
134    seed: &auths_crypto::TypedSeed,
135    canonical: &[u8],
136) -> Result<Vec<u8>, RotationError> {
137    auths_crypto::typed_sign(seed, canonical)
138        .map_err(|e| RotationError::Serialization(format!("signing failed: {e}")))
139}
140
141/// Rotate keys for a KERI identity stored in a Git repository.
142pub fn rotate_keys(
143    repo: &git2::Repository,
144    prefix: &Prefix,
145    next_keypair_pkcs8: &Pkcs8Der,
146    witness_config: Option<&WitnessConfig>,
147    now: chrono::DateTime<chrono::Utc>,
148) -> Result<RotationResult, RotationError> {
149    let kel = GitKel::new(repo, prefix.as_str());
150    let events = kel.get_events()?;
151    let state = validate_kel(&events)?;
152
153    if !state.can_rotate() {
154        return Err(RotationError::IdentityAbandoned);
155    }
156
157    let curve = detect_curve_from_state(&state);
158    let (next_pub_bytes, next_seed, next_cesr) = parse_next_key(next_keypair_pkcs8.as_ref())?;
159
160    #[allow(clippy::expect_used)]
161    // INVARIANT: next_cesr is produced by parse_next_key and always parses
162    let next_verkey =
163        auths_keri::KeriPublicKey::parse(&next_cesr).expect("valid CESR from parse_next_key");
164    if !verify_commitment(&next_verkey, &state.next_commitment[0]) {
165        return Err(RotationError::CommitmentMismatch);
166    }
167
168    let new_next = generate_keypair_for_init(curve)
169        .map_err(|e| RotationError::KeyGeneration(e.to_string()))?;
170
171    let new_next_commitment = compute_next_commitment(&new_next.verkey());
172
173    let bt = match witness_config {
174        Some(cfg) if cfg.is_enabled() => Threshold::Simple(cfg.threshold as u64),
175        _ => Threshold::Simple(0),
176    };
177
178    let new_sequence = state.sequence + 1;
179    let mut rot = RotEvent {
180        v: VersionString::placeholder(),
181        d: Said::default(),
182        i: prefix.clone(),
183        s: KeriSequence::new(new_sequence),
184        p: state.last_event_said.clone(),
185        kt: Threshold::Simple(1),
186        k: vec![CesrKey::new_unchecked(next_cesr.clone())],
187        nt: Threshold::Simple(1),
188        n: vec![new_next_commitment],
189        bt,
190        br: vec![],
191        ba: vec![],
192        c: vec![],
193        a: vec![],
194    };
195
196    let rot_value = serde_json::to_value(Event::Rot(rot.clone()))
197        .map_err(|e| RotationError::Serialization(e.to_string()))?;
198    rot.d = compute_said(&rot_value).map_err(|e| RotationError::Serialization(e.to_string()))?;
199
200    let canonical = super::serialize_for_signing(&Event::Rot(rot.clone()))?;
201    let _sig = sign_rotation(&next_seed, &canonical)?;
202
203    kel.append(&Event::Rot(rot.clone()), now)?;
204
205    #[cfg(feature = "witness-client")]
206    if let Some(config) = witness_config
207        && config.is_enabled()
208    {
209        let canonical_for_witness = super::serialize_for_signing(&Event::Rot(rot.clone()))?;
210        super::witness_integration::collect_and_store_receipts(
211            repo.path().parent().unwrap_or(repo.path()),
212            prefix,
213            &rot.d,
214            &canonical_for_witness,
215            config,
216            now,
217        )
218        .map_err(|e| RotationError::Serialization(e.to_string()))?;
219    }
220
221    Ok(RotationResult {
222        prefix: prefix.clone(),
223        sequence: new_sequence,
224        new_current_keypair_pkcs8: next_keypair_pkcs8.clone(),
225        new_next_keypair_pkcs8: new_next.pkcs8,
226        new_current_public_key: next_pub_bytes,
227        new_next_public_key: new_next.public_key,
228    })
229}
230
231/// Abandon a KERI identity by rotating with an empty next commitment.
232pub fn abandon_identity(
233    repo: &git2::Repository,
234    prefix: &Prefix,
235    next_keypair_pkcs8: &Pkcs8Der,
236    witness_config: Option<&WitnessConfig>,
237    now: chrono::DateTime<chrono::Utc>,
238) -> Result<u128, RotationError> {
239    let kel = GitKel::new(repo, prefix.as_str());
240    let events = kel.get_events()?;
241    let state = validate_kel(&events)?;
242
243    if state.is_abandoned {
244        return Err(RotationError::IdentityAbandoned);
245    }
246
247    let (_next_pub_bytes, next_seed, next_cesr) = parse_next_key(next_keypair_pkcs8.as_ref())?;
248
249    #[allow(clippy::expect_used)]
250    // INVARIANT: next_cesr is produced by parse_next_key and always parses
251    let next_verkey =
252        auths_keri::KeriPublicKey::parse(&next_cesr).expect("valid CESR from parse_next_key");
253    if !verify_commitment(&next_verkey, &state.next_commitment[0]) {
254        return Err(RotationError::CommitmentMismatch);
255    }
256
257    let bt = match witness_config {
258        Some(cfg) if cfg.is_enabled() => Threshold::Simple(cfg.threshold as u64),
259        _ => Threshold::Simple(0),
260    };
261
262    let new_sequence = state.sequence + 1;
263    let mut rot = RotEvent {
264        v: VersionString::placeholder(),
265        d: Said::default(),
266        i: prefix.clone(),
267        s: KeriSequence::new(new_sequence),
268        p: state.last_event_said.clone(),
269        kt: Threshold::Simple(1),
270        k: vec![CesrKey::new_unchecked(next_cesr.clone())],
271        nt: Threshold::Simple(0),
272        n: vec![],
273        bt,
274        br: vec![],
275        ba: vec![],
276        c: vec![],
277        a: vec![],
278    };
279
280    let rot_value = serde_json::to_value(Event::Rot(rot.clone()))
281        .map_err(|e| RotationError::Serialization(e.to_string()))?;
282    rot.d = compute_said(&rot_value).map_err(|e| RotationError::Serialization(e.to_string()))?;
283
284    let canonical = super::serialize_for_signing(&Event::Rot(rot.clone()))?;
285    let _sig = sign_rotation(&next_seed, &canonical)?;
286
287    kel.append(&Event::Rot(rot), now)?;
288
289    Ok(new_sequence)
290}
291
292/// Get the current key state for a KERI identity.
293pub fn get_key_state(
294    repo: &git2::Repository,
295    prefix: &Prefix,
296) -> Result<auths_keri::KeyState, RotationError> {
297    let kel = GitKel::new(repo, prefix.as_str());
298    let events = kel.get_events()?;
299    let state = validate_kel(&events)?;
300    Ok(state)
301}
302
303/// Rotate keys using any [`RegistryBackend`].
304pub fn rotate_keys_with_backend(
305    backend: &impl RegistryBackend,
306    prefix: &Prefix,
307    next_keypair_pkcs8: &Pkcs8Der,
308    _now: chrono::DateTime<chrono::Utc>,
309    _witness_config: Option<&WitnessConfig>,
310) -> Result<RotationResult, RotationError> {
311    let events = collect_events_from_backend(backend, prefix)?;
312    let state = validate_kel(&events)?;
313
314    if !state.can_rotate() {
315        return Err(RotationError::IdentityAbandoned);
316    }
317
318    let curve = detect_curve_from_state(&state);
319    let (next_pub_bytes, next_seed, next_cesr) = parse_next_key(next_keypair_pkcs8.as_ref())?;
320
321    #[allow(clippy::expect_used)]
322    // INVARIANT: next_cesr is produced by parse_next_key and always parses
323    let next_verkey =
324        auths_keri::KeriPublicKey::parse(&next_cesr).expect("valid CESR from parse_next_key");
325    if !verify_commitment(&next_verkey, &state.next_commitment[0]) {
326        return Err(RotationError::CommitmentMismatch);
327    }
328
329    let new_next = generate_keypair_for_init(curve)
330        .map_err(|e| RotationError::KeyGeneration(e.to_string()))?;
331
332    let new_next_commitment = compute_next_commitment(&new_next.verkey());
333
334    let new_sequence = state.sequence + 1;
335    let mut rot = RotEvent {
336        v: VersionString::placeholder(),
337        d: Said::default(),
338        i: prefix.clone(),
339        s: KeriSequence::new(new_sequence),
340        p: state.last_event_said.clone(),
341        kt: Threshold::Simple(1),
342        k: vec![CesrKey::new_unchecked(next_cesr.clone())],
343        nt: Threshold::Simple(1),
344        n: vec![new_next_commitment],
345        bt: Threshold::Simple(0),
346        br: vec![],
347        ba: vec![],
348        c: vec![],
349        a: vec![],
350    };
351
352    let rot_value = serde_json::to_value(Event::Rot(rot.clone()))
353        .map_err(|e| RotationError::Serialization(e.to_string()))?;
354    rot.d = compute_said(&rot_value).map_err(|e| RotationError::Serialization(e.to_string()))?;
355
356    let canonical = super::serialize_for_signing(&Event::Rot(rot.clone()))
357        .map_err(|e| RotationError::Serialization(e.to_string()))?;
358    let sig = sign_rotation(&next_seed, &canonical)?;
359    let attachment = auths_keri::serialize_attachment(&[auths_keri::IndexedSignature {
360        index: 0,
361        prior_index: None,
362        sig,
363    }])
364    .map_err(|e| RotationError::Serialization(e.to_string()))?;
365
366    backend
367        .append_signed_event(prefix, &Event::Rot(rot), &attachment)
368        .map_err(RotationError::Storage)?;
369
370    Ok(RotationResult {
371        prefix: prefix.clone(),
372        sequence: new_sequence,
373        new_current_keypair_pkcs8: next_keypair_pkcs8.clone(),
374        new_next_keypair_pkcs8: new_next.pkcs8,
375        new_current_public_key: next_pub_bytes,
376        new_next_public_key: new_next.public_key,
377    })
378}
379
380/// Get the current key state from any [`RegistryBackend`].
381pub fn get_key_state_with_backend(
382    backend: &impl RegistryBackend,
383    prefix: &Prefix,
384) -> Result<auths_keri::KeyState, RotationError> {
385    let events = collect_events_from_backend(backend, prefix)?;
386    let state = validate_kel(&events)?;
387    Ok(state)
388}
389
390fn collect_events_from_backend(
391    backend: &impl RegistryBackend,
392    prefix: &Prefix,
393) -> Result<Vec<Event>, RotationError> {
394    let mut events = Vec::new();
395    backend
396        .visit_events(prefix, 0, &mut |e| {
397            events.push(e.clone());
398            ControlFlow::Continue(())
399        })
400        .map_err(RotationError::Storage)?;
401
402    if events.is_empty() {
403        return Err(RotationError::Kel(KelError::NotFound(
404            prefix.as_str().to_string(),
405        )));
406    }
407    Ok(events)
408}
409
410#[cfg(test)]
411#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
412mod tests {
413    use super::*;
414    use crate::keri::create_keri_identity_with_curve;
415
416    #[test]
417    fn rotation_updates_key_and_sequence() {
418        let (_dir, repo) = auths_test_utils::git::init_test_repo();
419        let init = create_keri_identity_with_curve(
420            &repo,
421            None,
422            chrono::Utc::now(),
423            auths_crypto::CurveType::Ed25519,
424        )
425        .unwrap();
426
427        let rot = rotate_keys(
428            &repo,
429            &init.prefix,
430            &init.next_keypair_pkcs8,
431            None,
432            chrono::Utc::now(),
433        )
434        .unwrap();
435        assert_eq!(rot.sequence, 1);
436        assert_ne!(rot.new_current_public_key, init.current_public_key);
437    }
438
439    #[test]
440    fn rotation_verifies_commitment() {
441        let (_dir, repo) = auths_test_utils::git::init_test_repo();
442        let init = create_keri_identity_with_curve(
443            &repo,
444            None,
445            chrono::Utc::now(),
446            auths_crypto::CurveType::Ed25519,
447        )
448        .unwrap();
449
450        let wrong_key = Pkcs8Der::new([99u8; 85].to_vec());
451        let result = rotate_keys(&repo, &init.prefix, &wrong_key, None, chrono::Utc::now());
452        assert!(result.is_err());
453    }
454
455    #[test]
456    fn rotation_chain_works() {
457        let (_dir, repo) = auths_test_utils::git::init_test_repo();
458        let init = create_keri_identity_with_curve(
459            &repo,
460            None,
461            chrono::Utc::now(),
462            auths_crypto::CurveType::Ed25519,
463        )
464        .unwrap();
465
466        let rot1 = rotate_keys(
467            &repo,
468            &init.prefix,
469            &init.next_keypair_pkcs8,
470            None,
471            chrono::Utc::now(),
472        )
473        .unwrap();
474        assert_eq!(rot1.sequence, 1);
475
476        let rot2 = rotate_keys(
477            &repo,
478            &init.prefix,
479            &rot1.new_next_keypair_pkcs8,
480            None,
481            chrono::Utc::now(),
482        )
483        .unwrap();
484        assert_eq!(rot2.sequence, 2);
485    }
486
487    #[test]
488    fn state_reflects_rotation() {
489        let (_dir, repo) = auths_test_utils::git::init_test_repo();
490        let init = create_keri_identity_with_curve(
491            &repo,
492            None,
493            chrono::Utc::now(),
494            auths_crypto::CurveType::Ed25519,
495        )
496        .unwrap();
497
498        let _rot = rotate_keys(
499            &repo,
500            &init.prefix,
501            &init.next_keypair_pkcs8,
502            None,
503            chrono::Utc::now(),
504        )
505        .unwrap();
506        let state = get_key_state(&repo, &init.prefix).unwrap();
507        assert_eq!(state.sequence, 1);
508    }
509
510    #[test]
511    fn abandonment_works() {
512        let (_dir, repo) = auths_test_utils::git::init_test_repo();
513        let init = create_keri_identity_with_curve(
514            &repo,
515            None,
516            chrono::Utc::now(),
517            auths_crypto::CurveType::Ed25519,
518        )
519        .unwrap();
520
521        let seq = abandon_identity(
522            &repo,
523            &init.prefix,
524            &init.next_keypair_pkcs8,
525            None,
526            chrono::Utc::now(),
527        )
528        .unwrap();
529        assert_eq!(seq, 1);
530
531        let state = get_key_state(&repo, &init.prefix).unwrap();
532        assert!(state.is_abandoned);
533    }
534
535    #[test]
536    fn abandoned_identity_cannot_rotate() {
537        let (_dir, repo) = auths_test_utils::git::init_test_repo();
538        let init = create_keri_identity_with_curve(
539            &repo,
540            None,
541            chrono::Utc::now(),
542            auths_crypto::CurveType::Ed25519,
543        )
544        .unwrap();
545
546        abandon_identity(
547            &repo,
548            &init.prefix,
549            &init.next_keypair_pkcs8,
550            None,
551            chrono::Utc::now(),
552        )
553        .unwrap();
554
555        let wrong = Pkcs8Der::new(vec![1, 2, 3]);
556        let result = rotate_keys(&repo, &init.prefix, &wrong, None, chrono::Utc::now());
557        assert!(matches!(result, Err(RotationError::IdentityAbandoned)));
558    }
559
560    #[test]
561    fn double_abandonment_fails() {
562        let (_dir, repo) = auths_test_utils::git::init_test_repo();
563        let init = create_keri_identity_with_curve(
564            &repo,
565            None,
566            chrono::Utc::now(),
567            auths_crypto::CurveType::Ed25519,
568        )
569        .unwrap();
570
571        abandon_identity(
572            &repo,
573            &init.prefix,
574            &init.next_keypair_pkcs8,
575            None,
576            chrono::Utc::now(),
577        )
578        .unwrap();
579        let result = abandon_identity(
580            &repo,
581            &init.prefix,
582            &init.next_keypair_pkcs8,
583            None,
584            chrono::Utc::now(),
585        );
586        assert!(matches!(result, Err(RotationError::IdentityAbandoned)));
587    }
588
589    #[test]
590    fn p256_rotation_works() {
591        let (_dir, repo) = auths_test_utils::git::init_test_repo();
592        let init = create_keri_identity_with_curve(
593            &repo,
594            None,
595            chrono::Utc::now(),
596            auths_crypto::CurveType::P256,
597        )
598        .unwrap();
599
600        let rot = rotate_keys(
601            &repo,
602            &init.prefix,
603            &init.next_keypair_pkcs8,
604            None,
605            chrono::Utc::now(),
606        )
607        .unwrap();
608        assert_eq!(rot.sequence, 1);
609        assert_eq!(rot.new_current_public_key.len(), 33);
610        assert_eq!(rot.new_next_public_key.len(), 33);
611    }
612
613    #[test]
614    fn p256_rotation_chain_works() {
615        let (_dir, repo) = auths_test_utils::git::init_test_repo();
616        let init = create_keri_identity_with_curve(
617            &repo,
618            None,
619            chrono::Utc::now(),
620            auths_crypto::CurveType::P256,
621        )
622        .unwrap();
623
624        let rot1 = rotate_keys(
625            &repo,
626            &init.prefix,
627            &init.next_keypair_pkcs8,
628            None,
629            chrono::Utc::now(),
630        )
631        .unwrap();
632        let rot2 = rotate_keys(
633            &repo,
634            &init.prefix,
635            &rot1.new_next_keypair_pkcs8,
636            None,
637            chrono::Utc::now(),
638        )
639        .unwrap();
640        assert_eq!(rot2.sequence, 2);
641        assert_eq!(rot2.new_next_public_key.len(), 33);
642    }
643}