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 base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
12use git2::Repository;
13use ring::rand::SystemRandom;
14use ring::signature::{Ed25519KeyPair, KeyPair};
15
16use auths_core::crypto::said::{compute_next_commitment, compute_said, verify_commitment};
17
18use super::event::KeriSequence;
19use super::types::{Prefix, Said};
20use super::{
21    Event, GitKel, KERI_VERSION, KelError, KeyState, RotEvent, ValidationError, validate_kel,
22};
23use crate::storage::registry::backend::{RegistryBackend, RegistryError};
24use crate::witness_config::WitnessConfig;
25
26/// Error type for rotation operations.
27#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum RotationError {
30    #[error("Key generation failed: {0}")]
31    KeyGeneration(String),
32
33    #[error("KEL error: {0}")]
34    Kel(#[from] KelError),
35
36    #[error("Storage error: {0}")]
37    Storage(RegistryError),
38
39    #[error("Validation error: {0}")]
40    Validation(#[from] ValidationError),
41
42    #[error("Identity is abandoned (cannot rotate)")]
43    IdentityAbandoned,
44
45    #[error("Commitment mismatch: next key does not match previous commitment")]
46    CommitmentMismatch,
47
48    #[error("Serialization error: {0}")]
49    Serialization(String),
50
51    #[error("Invalid key: {0}")]
52    InvalidKey(String),
53}
54
55impl auths_core::error::AuthsErrorInfo for RotationError {
56    fn error_code(&self) -> &'static str {
57        match self {
58            Self::KeyGeneration(_) => "AUTHS-E4701",
59            Self::Kel(_) => "AUTHS-E4702",
60            Self::Storage(_) => "AUTHS-E4703",
61            Self::Validation(_) => "AUTHS-E4704",
62            Self::IdentityAbandoned => "AUTHS-E4705",
63            Self::CommitmentMismatch => "AUTHS-E4706",
64            Self::Serialization(_) => "AUTHS-E4707",
65            Self::InvalidKey(_) => "AUTHS-E4708",
66        }
67    }
68
69    fn suggestion(&self) -> Option<&'static str> {
70        match self {
71            Self::KeyGeneration(_) => None,
72            Self::Kel(_) => Some("Check the KEL state for the identity"),
73            Self::Storage(_) => Some("Check storage backend connectivity"),
74            Self::Validation(_) => None,
75            Self::IdentityAbandoned => {
76                Some("This identity has been abandoned and cannot be rotated")
77            }
78            Self::CommitmentMismatch => {
79                Some("The provided next key does not match the pre-rotation commitment")
80            }
81            Self::Serialization(_) => None,
82            Self::InvalidKey(_) => Some("Provide a valid Ed25519 key in PKCS#8 format"),
83        }
84    }
85}
86
87/// Result of a KERI key rotation.
88pub struct RotationResult {
89    /// The KERI prefix
90    pub prefix: Prefix,
91
92    /// The new sequence number
93    pub sequence: u64,
94
95    /// The new current keypair (was the "next" key, PKCS8 DER encoded, zeroed on drop)
96    pub new_current_keypair_pkcs8: Pkcs8Der,
97
98    /// The new next keypair for future rotation (PKCS8 DER encoded, zeroed on drop)
99    pub new_next_keypair_pkcs8: Pkcs8Der,
100
101    /// The new current public key (raw 32 bytes)
102    pub new_current_public_key: Vec<u8>,
103
104    /// The new next public key (raw 32 bytes)
105    pub new_next_public_key: Vec<u8>,
106}
107
108impl std::fmt::Debug for RotationResult {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.debug_struct("RotationResult")
111            .field("prefix", &self.prefix)
112            .field("sequence", &self.sequence)
113            .field("new_current_keypair_pkcs8", &"[REDACTED]")
114            .field("new_next_keypair_pkcs8", &"[REDACTED]")
115            .field("new_current_public_key", &self.new_current_public_key)
116            .field("new_next_public_key", &self.new_next_public_key)
117            .finish()
118    }
119}
120
121/// Rotate keys for a KERI identity.
122///
123/// This verifies the provided next key matches the previous commitment,
124/// then generates a new next-key for future rotation.
125///
126/// # Arguments
127/// * `repo` - Git repository containing the KEL
128/// * `prefix` - The KERI identifier prefix
129/// * `next_keypair_pkcs8` - The next key (must match the commitment from the previous event)
130/// * `witness_config` - Optional witness configuration. When provided and
131///   enabled, the rotation event's `bt`/`b` fields are updated.
132///
133/// # Returns
134/// * `RotationResult` containing the new sequence and keypairs
135pub fn rotate_keys(
136    repo: &Repository,
137    prefix: &Prefix,
138    next_keypair_pkcs8: &Pkcs8Der,
139    witness_config: Option<&WitnessConfig>,
140    now: chrono::DateTime<chrono::Utc>,
141) -> Result<RotationResult, RotationError> {
142    let rng = SystemRandom::new();
143
144    // Load current state from KEL
145    let kel = GitKel::new(repo, prefix.as_str());
146    let events = kel.get_events()?;
147    let state = validate_kel(&events)?;
148
149    // Check if rotation is possible
150    if !state.can_rotate() {
151        return Err(RotationError::IdentityAbandoned);
152    }
153
154    // Parse the next keypair (supports multiple PKCS#8 formats and raw seeds)
155    let next_keypair =
156        crate::identity::helpers::load_keypair_from_der_or_seed(next_keypair_pkcs8.as_ref())
157            .map_err(|e| RotationError::InvalidKey(e.to_string()))?;
158
159    // Verify the next key matches the commitment
160    if !verify_commitment(
161        next_keypair.public_key().as_ref(),
162        &state.next_commitment[0],
163    ) {
164        return Err(RotationError::CommitmentMismatch);
165    }
166
167    // Generate new next key for future rotation
168    let new_next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
169        .map_err(|e| RotationError::KeyGeneration(e.to_string()))?;
170    let new_next_keypair = Ed25519KeyPair::from_pkcs8(new_next_pkcs8.as_ref())
171        .map_err(|e| RotationError::KeyGeneration(e.to_string()))?;
172
173    // Encode the new current key (the former next key)
174    let new_current_pub_encoded = format!(
175        "D{}",
176        URL_SAFE_NO_PAD.encode(next_keypair.public_key().as_ref())
177    );
178
179    // Compute new next-key commitment
180    let new_next_commitment = compute_next_commitment(new_next_keypair.public_key().as_ref());
181
182    // Determine witness fields from config
183    let (bt, b) = match witness_config {
184        Some(cfg) if cfg.is_enabled() => (
185            cfg.threshold.to_string(),
186            cfg.witness_urls.iter().map(|u| u.to_string()).collect(),
187        ),
188        _ => ("0".to_string(), vec![]),
189    };
190
191    // Build rotation event
192    let new_sequence = state.sequence + 1;
193    let mut rot = RotEvent {
194        v: KERI_VERSION.to_string(),
195        d: Said::default(),
196        i: prefix.clone(),
197        s: KeriSequence::new(new_sequence),
198        p: state.last_event_said.clone(),
199        kt: "1".to_string(),
200        k: vec![new_current_pub_encoded],
201        nt: "1".to_string(),
202        n: vec![new_next_commitment],
203        bt,
204        b,
205        a: vec![],
206        x: String::new(),
207    };
208
209    // Compute SAID
210    let rot_json = serde_json::to_vec(&Event::Rot(rot.clone()))
211        .map_err(|e| RotationError::Serialization(e.to_string()))?;
212    rot.d = compute_said(&rot_json);
213
214    // Sign with the new current key (next_keypair is now the active key)
215    let canonical = super::serialize_for_signing(&Event::Rot(rot.clone()))?;
216    let sig = next_keypair.sign(&canonical);
217    rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref());
218
219    // Append to KEL
220    kel.append(&Event::Rot(rot.clone()), now)?;
221
222    // Collect witness receipts if configured
223    #[cfg(feature = "witness-client")]
224    if let Some(config) = witness_config
225        && config.is_enabled()
226    {
227        let canonical_for_witness = super::serialize_for_signing(&Event::Rot(rot.clone()))?;
228        super::witness_integration::collect_and_store_receipts(
229            repo.path().parent().unwrap_or(repo.path()),
230            prefix,
231            &rot.d,
232            &canonical_for_witness,
233            config,
234            now,
235        )
236        .map_err(|e| RotationError::Serialization(e.to_string()))?;
237    }
238
239    Ok(RotationResult {
240        prefix: prefix.clone(),
241        sequence: new_sequence,
242        new_current_keypair_pkcs8: next_keypair_pkcs8.clone(),
243        new_next_keypair_pkcs8: Pkcs8Der::new(new_next_pkcs8.as_ref()),
244        new_current_public_key: next_keypair.public_key().as_ref().to_vec(),
245        new_next_public_key: new_next_keypair.public_key().as_ref().to_vec(),
246    })
247}
248
249/// Abandon a KERI identity by rotating with an empty next commitment.
250///
251/// After abandonment, the identity can no longer be rotated.
252/// This still requires using the committed next key for the final rotation.
253///
254/// # Arguments
255/// * `repo` - Git repository containing the KEL
256/// * `prefix` - The KERI identifier prefix
257/// * `next_keypair_pkcs8` - The next key (must match commitment, will become final key)
258/// * `witness_config` - Optional witness configuration
259pub fn abandon_identity(
260    repo: &Repository,
261    prefix: &Prefix,
262    next_keypair_pkcs8: &Pkcs8Der,
263    witness_config: Option<&WitnessConfig>,
264    now: chrono::DateTime<chrono::Utc>,
265) -> Result<u64, RotationError> {
266    // Load current state from KEL
267    let kel = GitKel::new(repo, prefix.as_str());
268    let events = kel.get_events()?;
269    let state = validate_kel(&events)?;
270
271    // Check if already abandoned
272    if state.is_abandoned {
273        return Err(RotationError::IdentityAbandoned);
274    }
275
276    // Parse the next keypair
277    let next_keypair = Ed25519KeyPair::from_pkcs8(next_keypair_pkcs8.as_ref())
278        .map_err(|e| RotationError::InvalidKey(e.to_string()))?;
279
280    // Verify the next key matches the commitment
281    if !verify_commitment(
282        next_keypair.public_key().as_ref(),
283        &state.next_commitment[0],
284    ) {
285        return Err(RotationError::CommitmentMismatch);
286    }
287
288    // Encode the new current key (the former next key)
289    let new_current_pub_encoded = format!(
290        "D{}",
291        URL_SAFE_NO_PAD.encode(next_keypair.public_key().as_ref())
292    );
293
294    // Determine witness fields from config
295    let (bt, b) = match witness_config {
296        Some(cfg) if cfg.is_enabled() => (
297            cfg.threshold.to_string(),
298            cfg.witness_urls.iter().map(|u| u.to_string()).collect(),
299        ),
300        _ => ("0".to_string(), vec![]),
301    };
302
303    // Build abandonment rotation event (empty next commitment)
304    let new_sequence = state.sequence + 1;
305    let mut rot = RotEvent {
306        v: KERI_VERSION.to_string(),
307        d: Said::default(),
308        i: prefix.clone(),
309        s: KeriSequence::new(new_sequence),
310        p: state.last_event_said.clone(),
311        kt: "1".to_string(),
312        k: vec![new_current_pub_encoded], // Rotate to next key
313        nt: "0".to_string(),              // Zero threshold
314        n: vec![],                        // Empty = abandoned
315        bt,
316        b,
317        a: vec![],
318        x: String::new(),
319    };
320
321    // Compute SAID
322    let rot_json = serde_json::to_vec(&Event::Rot(rot.clone()))
323        .map_err(|e| RotationError::Serialization(e.to_string()))?;
324    rot.d = compute_said(&rot_json);
325
326    // Sign with the new current key (next_keypair is now the active key)
327    let canonical = super::serialize_for_signing(&Event::Rot(rot.clone()))?;
328    let sig = next_keypair.sign(&canonical);
329    rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref());
330
331    // Append to KEL
332    kel.append(&Event::Rot(rot), now)?;
333
334    Ok(new_sequence)
335}
336
337/// Get the current key state for a KERI identity.
338pub fn get_key_state(repo: &Repository, prefix: &Prefix) -> Result<KeyState, RotationError> {
339    let kel = GitKel::new(repo, prefix.as_str());
340    let events = kel.get_events()?;
341    let state = validate_kel(&events)?;
342    Ok(state)
343}
344
345/// Rotate keys for a KERI identity using any [`RegistryBackend`].
346///
347/// Identical logic to [`rotate_keys`] but reads from and writes to the
348/// provided backend instead of a git repository.
349///
350/// # Arguments
351/// * `backend` - The registry backend to read/write the KEL
352/// * `prefix` - The KERI identifier prefix
353/// * `next_keypair_pkcs8` - The next key (must match the commitment from the previous event)
354/// * `witness_config` - Unused in the backend path; kept for API symmetry
355pub fn rotate_keys_with_backend(
356    backend: &impl RegistryBackend,
357    prefix: &Prefix,
358    next_keypair_pkcs8: &Pkcs8Der,
359    _now: chrono::DateTime<chrono::Utc>,
360    _witness_config: Option<&WitnessConfig>,
361) -> Result<RotationResult, RotationError> {
362    let rng = SystemRandom::new();
363
364    // Load current state from backend
365    let events = collect_events_from_backend(backend, prefix)?;
366    let state = validate_kel(&events)?;
367
368    if !state.can_rotate() {
369        return Err(RotationError::IdentityAbandoned);
370    }
371
372    let next_keypair =
373        crate::identity::helpers::load_keypair_from_der_or_seed(next_keypair_pkcs8.as_ref())
374            .map_err(|e| RotationError::InvalidKey(e.to_string()))?;
375
376    if !verify_commitment(
377        next_keypair.public_key().as_ref(),
378        &state.next_commitment[0],
379    ) {
380        return Err(RotationError::CommitmentMismatch);
381    }
382
383    let new_next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
384        .map_err(|e| RotationError::KeyGeneration(e.to_string()))?;
385    let new_next_keypair = Ed25519KeyPair::from_pkcs8(new_next_pkcs8.as_ref())
386        .map_err(|e| RotationError::KeyGeneration(e.to_string()))?;
387
388    let new_current_pub_encoded = format!(
389        "D{}",
390        URL_SAFE_NO_PAD.encode(next_keypair.public_key().as_ref())
391    );
392    let new_next_commitment = compute_next_commitment(new_next_keypair.public_key().as_ref());
393
394    let new_sequence = state.sequence + 1;
395    let mut rot = RotEvent {
396        v: KERI_VERSION.to_string(),
397        d: Said::default(),
398        i: prefix.clone(),
399        s: KeriSequence::new(new_sequence),
400        p: state.last_event_said.clone(),
401        kt: "1".to_string(),
402        k: vec![new_current_pub_encoded],
403        nt: "1".to_string(),
404        n: vec![new_next_commitment],
405        bt: "0".to_string(),
406        b: vec![],
407        a: vec![],
408        x: String::new(),
409    };
410
411    let rot_json = serde_json::to_vec(&Event::Rot(rot.clone()))
412        .map_err(|e| RotationError::Serialization(e.to_string()))?;
413    rot.d = compute_said(&rot_json);
414
415    let canonical = super::serialize_for_signing(&Event::Rot(rot.clone()))
416        .map_err(|e| RotationError::Serialization(e.to_string()))?;
417    let sig = next_keypair.sign(&canonical);
418    rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref());
419
420    backend
421        .append_event(prefix, &Event::Rot(rot))
422        .map_err(RotationError::Storage)?;
423
424    Ok(RotationResult {
425        prefix: prefix.clone(),
426        sequence: new_sequence,
427        new_current_keypair_pkcs8: next_keypair_pkcs8.clone(),
428        new_next_keypair_pkcs8: Pkcs8Der::new(new_next_pkcs8.as_ref()),
429        new_current_public_key: next_keypair.public_key().as_ref().to_vec(),
430        new_next_public_key: new_next_keypair.public_key().as_ref().to_vec(),
431    })
432}
433
434/// Get the current key state for a KERI identity from any [`RegistryBackend`].
435///
436/// # Arguments
437/// * `backend` - The registry backend to read from
438/// * `prefix` - The KERI identifier prefix
439pub fn get_key_state_with_backend(
440    backend: &impl RegistryBackend,
441    prefix: &Prefix,
442) -> Result<KeyState, RotationError> {
443    backend
444        .get_key_state(prefix)
445        .map_err(RotationError::Storage)
446}
447
448fn collect_events_from_backend(
449    backend: &impl RegistryBackend,
450    prefix: &Prefix,
451) -> Result<Vec<Event>, RotationError> {
452    let mut events = Vec::new();
453    backend
454        .visit_events(prefix, 0, &mut |e| {
455            events.push(e.clone());
456            ControlFlow::Continue(())
457        })
458        .map_err(RotationError::Storage)?;
459    Ok(events)
460}
461
462#[cfg(test)]
463#[allow(clippy::disallowed_methods)]
464mod tests {
465    use super::*;
466    use crate::keri::create_keri_identity;
467    use tempfile::TempDir;
468
469    fn setup_repo() -> (TempDir, Repository) {
470        let dir = TempDir::new().unwrap();
471        let repo = Repository::init(dir.path()).unwrap();
472
473        let mut config = repo.config().unwrap();
474        config.set_str("user.name", "Test User").unwrap();
475        config.set_str("user.email", "test@example.com").unwrap();
476
477        (dir, repo)
478    }
479
480    #[test]
481    fn rotation_updates_key_and_sequence() {
482        let (_dir, repo) = setup_repo();
483
484        // Create identity
485        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
486
487        // Rotate using the next key
488        let rot = rotate_keys(
489            &repo,
490            &init.prefix,
491            &init.next_keypair_pkcs8,
492            None,
493            chrono::Utc::now(),
494        )
495        .unwrap();
496
497        assert_eq!(rot.prefix, init.prefix);
498        assert_eq!(rot.sequence, 1);
499
500        // Verify KEL has 2 events
501        let kel = GitKel::new(&repo, rot.prefix.as_str());
502        let events = kel.get_events().unwrap();
503        assert_eq!(events.len(), 2);
504        assert!(events[0].is_inception());
505        assert!(events[1].is_rotation());
506    }
507
508    #[test]
509    fn rotation_verifies_commitment() {
510        let (_dir, repo) = setup_repo();
511
512        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
513
514        // Try to rotate with a wrong key
515        let rng = SystemRandom::new();
516        let wrong_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
517        let wrong_pkcs8 = Pkcs8Der::new(wrong_pkcs8.as_ref());
518
519        let result = rotate_keys(&repo, &init.prefix, &wrong_pkcs8, None, chrono::Utc::now());
520        assert!(matches!(result, Err(RotationError::CommitmentMismatch)));
521    }
522
523    #[test]
524    fn rotation_chain_works() {
525        let (_dir, repo) = setup_repo();
526
527        // Create identity
528        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
529
530        // First rotation
531        let rot1 = rotate_keys(
532            &repo,
533            &init.prefix,
534            &init.next_keypair_pkcs8,
535            None,
536            chrono::Utc::now(),
537        )
538        .unwrap();
539        assert_eq!(rot1.sequence, 1);
540
541        // Second rotation
542        let rot2 = rotate_keys(
543            &repo,
544            &init.prefix,
545            &rot1.new_next_keypair_pkcs8,
546            None,
547            chrono::Utc::now(),
548        )
549        .unwrap();
550        assert_eq!(rot2.sequence, 2);
551
552        // Verify KEL has 3 events
553        let kel = GitKel::new(&repo, init.prefix.as_str());
554        let events = kel.get_events().unwrap();
555        assert_eq!(events.len(), 3);
556
557        // Validate the full chain
558        let state = validate_kel(&events).unwrap();
559        assert_eq!(state.sequence, 2);
560    }
561
562    #[test]
563    fn abandonment_works() {
564        let (_dir, repo) = setup_repo();
565
566        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
567
568        // Abandon the identity (must use next key)
569        let seq = abandon_identity(
570            &repo,
571            &init.prefix,
572            &init.next_keypair_pkcs8,
573            None,
574            chrono::Utc::now(),
575        )
576        .unwrap();
577        assert_eq!(seq, 1);
578
579        // Verify state
580        let state = get_key_state(&repo, &init.prefix).unwrap();
581        assert!(state.is_abandoned);
582        assert!(!state.can_rotate());
583    }
584
585    #[test]
586    fn abandoned_identity_cannot_rotate() {
587        let (_dir, repo) = setup_repo();
588
589        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
590
591        // Abandon first (uses next key)
592        abandon_identity(
593            &repo,
594            &init.prefix,
595            &init.next_keypair_pkcs8,
596            None,
597            chrono::Utc::now(),
598        )
599        .unwrap();
600
601        // Generate a new key and try to rotate - should fail because abandoned
602        let rng = SystemRandom::new();
603        let new_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
604        let new_pkcs8 = Pkcs8Der::new(new_pkcs8.as_ref());
605        let result = rotate_keys(&repo, &init.prefix, &new_pkcs8, None, chrono::Utc::now());
606        assert!(matches!(result, Err(RotationError::IdentityAbandoned)));
607    }
608
609    #[test]
610    fn double_abandonment_fails() {
611        let (_dir, repo) = setup_repo();
612
613        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
614
615        abandon_identity(
616            &repo,
617            &init.prefix,
618            &init.next_keypair_pkcs8,
619            None,
620            chrono::Utc::now(),
621        )
622        .unwrap();
623
624        // Generate a new key and try to abandon again
625        let rng = SystemRandom::new();
626        let new_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
627        let new_pkcs8 = Pkcs8Der::new(new_pkcs8.as_ref());
628        let result = abandon_identity(&repo, &init.prefix, &new_pkcs8, None, chrono::Utc::now());
629        assert!(matches!(result, Err(RotationError::IdentityAbandoned)));
630    }
631
632    #[test]
633    fn get_key_state_works() {
634        let (_dir, repo) = setup_repo();
635
636        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
637
638        let state = get_key_state(&repo, &init.prefix).unwrap();
639        assert_eq!(state.prefix, init.prefix);
640        assert_eq!(state.sequence, 0);
641        assert!(!state.is_abandoned);
642        assert!(state.can_rotate());
643    }
644
645    #[test]
646    fn state_reflects_rotation() {
647        let (_dir, repo) = setup_repo();
648
649        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
650        rotate_keys(
651            &repo,
652            &init.prefix,
653            &init.next_keypair_pkcs8,
654            None,
655            chrono::Utc::now(),
656        )
657        .unwrap();
658
659        let state = get_key_state(&repo, &init.prefix).unwrap();
660        assert_eq!(state.sequence, 1);
661
662        // Current key should be the former next key
663        let expected_key = format!("D{}", URL_SAFE_NO_PAD.encode(&init.next_public_key));
664        assert_eq!(state.current_keys[0], expected_key);
665    }
666}