Skip to main content

auths_sdk/workflows/
rotation.rs

1//! Identity rotation workflow.
2//!
3//! Three-phase design:
4//! 1. `compute_rotation_event` — pure, deterministic RotEvent construction.
5//! 2. `apply_rotation` — side-effecting KEL append + keychain write.
6//! 3. `rotate_identity` — high-level orchestrator (calls both phases in order).
7
8use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
9use ring::rand::SystemRandom;
10use ring::signature::{Ed25519KeyPair, KeyPair};
11use zeroize::Zeroizing;
12
13use auths_core::crypto::said::{compute_next_commitment, compute_said, verify_commitment};
14use auths_core::crypto::signer::{decrypt_keypair, encrypt_keypair, load_seed_and_pubkey};
15use auths_core::ports::clock::ClockProvider;
16use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage, extract_public_key_bytes};
17use auths_id::identity::helpers::{
18    ManagedIdentity, encode_seed_as_pkcs8, extract_seed_bytes, load_keypair_from_der_or_seed,
19};
20use auths_id::keri::{
21    Event, KERI_VERSION, KeriSequence, KeyState, Prefix, RotEvent, Said, serialize_for_signing,
22};
23use auths_id::ports::registry::RegistryBackend;
24use auths_id::witness_config::WitnessConfig;
25
26use crate::context::AuthsContext;
27use crate::error::RotationError;
28use crate::result::RotationResult;
29use crate::types::RotationConfig;
30
31/// Computes a KERI rotation event and its canonical serialization.
32///
33/// Pure function — deterministic given fixed inputs. Signs the event bytes with
34/// `next_keypair` (the pre-committed future key becoming the new current key).
35/// `new_next_keypair` is the freshly generated key committed for the next rotation.
36///
37/// Args:
38/// * `state`: Current key state from the registry.
39/// * `next_keypair`: Pre-committed next key (becomes new current signer after rotation).
40/// * `new_next_keypair`: Freshly generated keypair committed for the next rotation.
41/// * `witness_config`: Optional witness configuration.
42///
43/// Returns `(event, canonical_bytes)` where `canonical_bytes` is the exact
44/// byte sequence to write to the KEL — do not re-serialize.
45///
46/// Usage:
47/// ```ignore
48/// let (rot, bytes) = compute_rotation_event(&state, &next_kp, &new_next_kp, None)?;
49/// ```
50pub fn compute_rotation_event(
51    state: &KeyState,
52    next_keypair: &Ed25519KeyPair,
53    new_next_keypair: &Ed25519KeyPair,
54    witness_config: Option<&WitnessConfig>,
55) -> Result<(RotEvent, Vec<u8>), RotationError> {
56    let prefix = &state.prefix;
57
58    let new_current_pub_encoded = format!(
59        "D{}",
60        URL_SAFE_NO_PAD.encode(next_keypair.public_key().as_ref())
61    );
62    let new_next_commitment = compute_next_commitment(new_next_keypair.public_key().as_ref());
63
64    let (bt, b) = match witness_config {
65        Some(cfg) if cfg.is_enabled() => (
66            cfg.threshold.to_string(),
67            cfg.witness_urls.iter().map(|u| u.to_string()).collect(),
68        ),
69        _ => ("0".to_string(), vec![]),
70    };
71
72    let new_sequence = state.sequence + 1;
73    let mut rot = RotEvent {
74        v: KERI_VERSION.to_string(),
75        d: Said::default(),
76        i: prefix.clone(),
77        s: KeriSequence::new(new_sequence),
78        p: state.last_event_said.clone(),
79        kt: "1".to_string(),
80        k: vec![new_current_pub_encoded],
81        nt: "1".to_string(),
82        n: vec![new_next_commitment],
83        bt,
84        b,
85        a: vec![],
86        x: String::new(),
87    };
88
89    let rot_json = serde_json::to_vec(&Event::Rot(rot.clone()))
90        .map_err(|e| RotationError::RotationFailed(format!("serialization failed: {e}")))?;
91    rot.d = compute_said(&rot_json);
92
93    let canonical = serialize_for_signing(&Event::Rot(rot.clone()))
94        .map_err(|e| RotationError::RotationFailed(format!("serialize for signing failed: {e}")))?;
95    let sig = next_keypair.sign(&canonical);
96    rot.x = URL_SAFE_NO_PAD.encode(sig.as_ref());
97
98    let event_bytes = serialize_for_signing(&Event::Rot(rot.clone()))
99        .map_err(|e| RotationError::RotationFailed(format!("final serialization failed: {e}")))?;
100
101    Ok((rot, event_bytes))
102}
103
104/// Key material required for the keychain side of `apply_rotation`.
105pub struct RotationKeyMaterial {
106    /// DID of the identity being rotated.
107    pub did: IdentityDID,
108    /// Alias to store the new current key (the former pre-committed next key).
109    pub next_alias: KeyAlias,
110    /// Alias for the future pre-committed key (committed in this rotation).
111    pub new_next_alias: KeyAlias,
112    /// Pre-committed next key alias to delete after successful rotation.
113    pub old_next_alias: KeyAlias,
114    /// Encrypted new current key bytes to store in the keychain.
115    pub new_current_encrypted: Vec<u8>,
116    /// Encrypted new next key bytes to store for future rotation.
117    pub new_next_encrypted: Vec<u8>,
118}
119
120/// Applies a computed rotation event to the registry and keychain.
121///
122/// Writes the KEL event first, then updates the keychain. If the KEL append
123/// succeeds but the subsequent keychain write fails, returns
124/// `RotationError::PartialRotation` so the caller can surface a recovery path.
125///
126/// # NOTE: non-atomic — KEL and keychain writes are not transactional.
127/// Recovery: re-run rotation with the same new key to replay the keychain write.
128///
129/// Args:
130/// * `rot`: The pre-computed rotation event to append to the KEL.
131/// * `prefix`: KERI identifier prefix (the `did:keri:` suffix).
132/// * `key_material`: Encrypted key material and aliases for keychain operations.
133/// * `registry`: Registry backend for KEL append.
134/// * `key_storage`: Keychain for storing rotated key material.
135///
136/// Usage:
137/// ```ignore
138/// apply_rotation(&rot, prefix, key_material, registry.as_ref(), key_storage.as_ref())?;
139/// ```
140pub fn apply_rotation(
141    rot: &RotEvent,
142    prefix: &Prefix,
143    key_material: RotationKeyMaterial,
144    registry: &(dyn RegistryBackend + Send + Sync),
145    key_storage: &(dyn KeyStorage + Send + Sync),
146) -> Result<(), RotationError> {
147    registry
148        .append_event(prefix, &Event::Rot(rot.clone()))
149        .map_err(|e| RotationError::RotationFailed(format!("KEL append failed: {e}")))?;
150
151    // NOTE: non-atomic — KEL and keychain writes are not transactional.
152    // If the keychain write fails here, the KEL is already ahead.
153    let keychain_result = (|| {
154        key_storage
155            .store_key(
156                &key_material.next_alias,
157                &key_material.did,
158                &key_material.new_current_encrypted,
159            )
160            .map_err(|e| e.to_string())?;
161
162        key_storage
163            .store_key(
164                &key_material.new_next_alias,
165                &key_material.did,
166                &key_material.new_next_encrypted,
167            )
168            .map_err(|e| e.to_string())?;
169
170        let _ = key_storage.delete_key(&key_material.old_next_alias);
171
172        Ok::<(), String>(())
173    })();
174
175    keychain_result.map_err(RotationError::PartialRotation)
176}
177
178/// Rotates the signing keys for an existing KERI identity.
179///
180/// Args:
181/// * `config` - Configuration for the rotation including aliases and paths.
182/// * `ctx` - The application context containing storage adapters.
183/// * `clock` - Provider for timestamps.
184///
185/// Usage:
186/// ```ignore
187/// let result = rotate_identity(
188///     RotationConfig {
189///         repo_path: PathBuf::from("/home/user/.auths"),
190///         identity_key_alias: Some("main".into()),
191///         next_key_alias: None,
192///     },
193///     &ctx,
194///     &SystemClock,
195/// )?;
196/// println!("Rotated to: {}...", result.new_key_fingerprint);
197/// ```
198pub fn rotate_identity(
199    config: RotationConfig,
200    ctx: &AuthsContext,
201    clock: &dyn ClockProvider,
202) -> Result<RotationResult, RotationError> {
203    let (identity, prefix, current_alias) = resolve_rotation_context(&config, ctx)?;
204    let next_alias = config.next_key_alias.unwrap_or_else(|| {
205        KeyAlias::new_unchecked(format!(
206            "{}-rotated-{}",
207            current_alias,
208            clock.now().format("%Y%m%d%H%M%S")
209        ))
210    });
211
212    let previous_key_fingerprint = extract_previous_fingerprint(ctx, &current_alias)?;
213
214    let state = ctx
215        .registry
216        .get_key_state(&prefix)
217        .map_err(|e| RotationError::KelHistoryFailed(e.to_string()))?;
218
219    let (decrypted_next_pkcs8, old_next_alias) =
220        retrieve_precommitted_key(&identity.controller_did, &current_alias, &state, ctx)?;
221
222    let (rot, new_next_pkcs8) = generate_rotation_keys(&identity, &state, &decrypted_next_pkcs8)?;
223
224    finalize_rotation_storage(
225        FinalizeParams {
226            did: &identity.controller_did,
227            prefix: &prefix,
228            next_alias: &next_alias,
229            old_next_alias: &old_next_alias,
230            current_pkcs8: &decrypted_next_pkcs8,
231            new_next_pkcs8: new_next_pkcs8.as_ref(),
232            rot: &rot,
233            state: &state,
234        },
235        ctx,
236    )?;
237
238    let (_, new_pubkey) = load_seed_and_pubkey(&decrypted_next_pkcs8)
239        .map_err(|e| RotationError::RotationFailed(e.to_string()))?;
240
241    Ok(RotationResult {
242        controller_did: identity.controller_did,
243        new_key_fingerprint: hex::encode(&new_pubkey[..8]),
244        previous_key_fingerprint,
245    })
246}
247
248/// Resolves the identity and determines which key alias is currently active.
249fn resolve_rotation_context(
250    config: &RotationConfig,
251    ctx: &AuthsContext,
252) -> Result<(ManagedIdentity, Prefix, KeyAlias), RotationError> {
253    let identity =
254        ctx.identity_storage
255            .load_identity()
256            .map_err(|_| RotationError::IdentityNotFound {
257                path: config.repo_path.clone(),
258            })?;
259
260    let prefix_str = identity
261        .controller_did
262        .as_str()
263        .strip_prefix("did:keri:")
264        .ok_or_else(|| {
265            RotationError::RotationFailed(format!(
266                "invalid DID format, expected 'did:keri:': {}",
267                identity.controller_did
268            ))
269        })?;
270    let prefix = Prefix::new_unchecked(prefix_str.to_string());
271
272    let current_alias = match &config.identity_key_alias {
273        Some(alias) => alias.clone(),
274        None => {
275            let aliases = ctx
276                .key_storage
277                .list_aliases_for_identity(&identity.controller_did)
278                .map_err(|e| RotationError::RotationFailed(format!("alias lookup failed: {e}")))?;
279            aliases
280                .into_iter()
281                .find(|a| !a.contains("--next-"))
282                .ok_or_else(|| {
283                    RotationError::KeyNotFound(format!(
284                        "no active signing key for {}",
285                        identity.controller_did
286                    ))
287                })?
288        }
289    };
290
291    Ok((identity, prefix, current_alias))
292}
293
294fn extract_previous_fingerprint(
295    ctx: &AuthsContext,
296    current_alias: &KeyAlias,
297) -> Result<String, RotationError> {
298    let old_pubkey_bytes = extract_public_key_bytes(
299        ctx.key_storage.as_ref(),
300        current_alias,
301        ctx.passphrase_provider.as_ref(),
302    )
303    .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?;
304
305    Ok(hex::encode(&old_pubkey_bytes[..8]))
306}
307
308/// Retrieves and decrypts the key that was committed in the previous KERI event.
309fn retrieve_precommitted_key(
310    did: &IdentityDID,
311    current_alias: &KeyAlias,
312    state: &KeyState,
313    ctx: &AuthsContext,
314) -> Result<(Zeroizing<Vec<u8>>, KeyAlias), RotationError> {
315    let target_alias =
316        KeyAlias::new_unchecked(format!("{}--next-{}", current_alias, state.sequence));
317
318    let (did_check, encrypted_next) = ctx.key_storage.load_key(&target_alias).map_err(|e| {
319        RotationError::KeyNotFound(format!(
320            "pre-committed next key '{}' not found: {e}",
321            target_alias
322        ))
323    })?;
324
325    if did != &did_check {
326        return Err(RotationError::RotationFailed(format!(
327            "DID mismatch for pre-committed key '{}': expected {}, found {}",
328            target_alias, did, did_check
329        )));
330    }
331
332    let pass = ctx
333        .passphrase_provider
334        .get_passphrase(&format!(
335            "Enter passphrase for pre-committed key '{}':",
336            target_alias
337        ))
338        .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?;
339
340    let decrypted = decrypt_keypair(&encrypted_next, &pass)
341        .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?;
342
343    let keypair = load_keypair_from_der_or_seed(&decrypted)
344        .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?;
345
346    if !verify_commitment(keypair.public_key().as_ref(), &state.next_commitment[0]) {
347        return Err(RotationError::RotationFailed(
348            "commitment mismatch: next key does not match previous commitment".into(),
349        ));
350    }
351
352    Ok((decrypted, target_alias))
353}
354
355/// Generates the new rotation event and the next forward-looking key commitment.
356fn generate_rotation_keys(
357    identity: &ManagedIdentity,
358    state: &KeyState,
359    current_key_pkcs8: &[u8],
360) -> Result<(RotEvent, ring::pkcs8::Document), RotationError> {
361    let witness_config: Option<WitnessConfig> = identity
362        .metadata
363        .as_ref()
364        .and_then(|m| m.get("witness_config"))
365        .and_then(|wc| serde_json::from_value(wc.clone()).ok());
366
367    let rng = SystemRandom::new();
368    let new_next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
369        .map_err(|e| RotationError::RotationFailed(format!("key generation failed: {e}")))?;
370    let new_next_keypair = Ed25519KeyPair::from_pkcs8(new_next_pkcs8.as_ref())
371        .map_err(|e| RotationError::RotationFailed(format!("key construction failed: {e}")))?;
372
373    let next_keypair = load_keypair_from_der_or_seed(current_key_pkcs8)
374        .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?;
375
376    let (rot, _event_bytes) = compute_rotation_event(
377        state,
378        &next_keypair,
379        &new_next_keypair,
380        witness_config.as_ref(),
381    )?;
382
383    Ok((rot, new_next_pkcs8))
384}
385
386struct FinalizeParams<'a> {
387    did: &'a IdentityDID,
388    prefix: &'a Prefix,
389    next_alias: &'a KeyAlias,
390    old_next_alias: &'a KeyAlias,
391    current_pkcs8: &'a [u8],
392    new_next_pkcs8: &'a [u8],
393    rot: &'a RotEvent,
394    state: &'a KeyState,
395}
396
397/// Encrypts and persists the new current and next keys to secure storage.
398fn finalize_rotation_storage(
399    params: FinalizeParams<'_>,
400    ctx: &AuthsContext,
401) -> Result<(), RotationError> {
402    let new_pass = ctx
403        .passphrase_provider
404        .get_passphrase(&format!(
405            "Create passphrase for new key alias '{}':",
406            params.next_alias
407        ))
408        .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?;
409
410    let confirm_pass = ctx
411        .passphrase_provider
412        .get_passphrase(&format!("Confirm passphrase for '{}':", params.next_alias))
413        .map_err(|e| RotationError::KeyDecryptionFailed(e.to_string()))?;
414
415    if new_pass != confirm_pass {
416        return Err(RotationError::RotationFailed(format!(
417            "passphrases do not match for alias '{}'",
418            params.next_alias
419        )));
420    }
421
422    let encrypted_new_current = encrypt_keypair(params.current_pkcs8, &new_pass)
423        .map_err(|e| RotationError::RotationFailed(format!("encrypt new current key: {e}")))?;
424
425    let new_next_seed = extract_seed_bytes(params.new_next_pkcs8)
426        .map_err(|e| RotationError::RotationFailed(format!("extract new next seed: {e}")))?;
427    let new_next_seed_pkcs8 = encode_seed_as_pkcs8(new_next_seed)
428        .map_err(|e| RotationError::RotationFailed(format!("encode new next seed: {e}")))?;
429    let encrypted_new_next = encrypt_keypair(&new_next_seed_pkcs8, &new_pass)
430        .map_err(|e| RotationError::RotationFailed(format!("encrypt new next key: {e}")))?;
431
432    let new_sequence = params.state.sequence + 1;
433    let new_next_alias =
434        KeyAlias::new_unchecked(format!("{}--next-{}", params.next_alias, new_sequence));
435
436    let key_material = RotationKeyMaterial {
437        did: params.did.clone(),
438        next_alias: params.next_alias.clone(),
439        new_next_alias,
440        old_next_alias: params.old_next_alias.clone(),
441        new_current_encrypted: encrypted_new_current.to_vec(),
442        new_next_encrypted: encrypted_new_next.to_vec(),
443    };
444
445    apply_rotation(
446        params.rot,
447        params.prefix,
448        key_material,
449        ctx.registry.as_ref(),
450        ctx.key_storage.as_ref(),
451    )
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use std::sync::Arc;
458
459    use auths_core::PrefilledPassphraseProvider;
460    use auths_core::ports::clock::SystemClock;
461    use auths_core::signing::{PassphraseProvider, StorageSigner};
462    use auths_core::storage::memory::{MEMORY_KEYCHAIN, MemoryKeychainHandle};
463    use auths_id::attestation::export::AttestationSink;
464    use auths_id::ports::registry::RegistryBackend;
465    use auths_id::storage::attestation::AttestationSource;
466    use auths_id::storage::identity::IdentityStorage;
467    use auths_id::testing::fakes::FakeIdentityStorage;
468    use auths_id::testing::fakes::FakeRegistryBackend;
469    use auths_id::testing::fakes::{FakeAttestationSink, FakeAttestationSource};
470
471    use crate::setup::setup_developer;
472    use crate::types::{DeveloperSetupConfig, GitSigningScope};
473
474    fn fake_ctx(passphrase: &str) -> AuthsContext {
475        MEMORY_KEYCHAIN.lock().unwrap().clear_all().ok();
476        AuthsContext::builder()
477            .registry(
478                Arc::new(FakeRegistryBackend::new()) as Arc<dyn RegistryBackend + Send + Sync>
479            )
480            .key_storage(Arc::new(MemoryKeychainHandle))
481            .clock(Arc::new(SystemClock))
482            .identity_storage(
483                Arc::new(FakeIdentityStorage::new()) as Arc<dyn IdentityStorage + Send + Sync>
484            )
485            .attestation_sink(
486                Arc::new(FakeAttestationSink::new()) as Arc<dyn AttestationSink + Send + Sync>
487            )
488            .attestation_source(
489                Arc::new(FakeAttestationSource::new())
490                    as Arc<dyn AttestationSource + Send + Sync>,
491            )
492            .passphrase_provider(
493                Arc::new(PrefilledPassphraseProvider::new(passphrase))
494                    as Arc<dyn PassphraseProvider + Send + Sync>,
495            )
496            .build()
497            .unwrap()
498    }
499
500    fn provision_identity(ctx: &AuthsContext) -> KeyAlias {
501        let keychain = MemoryKeychainHandle;
502        let signer = StorageSigner::new(MemoryKeychainHandle);
503        let provider = PrefilledPassphraseProvider::new("Test-passphrase1!");
504        let config = DeveloperSetupConfig::builder(KeyAlias::new_unchecked("test-key"))
505            .with_git_signing_scope(GitSigningScope::Skip)
506            .build();
507        let result = setup_developer(config, ctx, &keychain, &signer, &provider, None).unwrap();
508        result.key_alias
509    }
510
511    // -- resolve_rotation_context --
512
513    #[test]
514    fn resolve_rotation_context_returns_identity_and_prefix() {
515        let ctx = fake_ctx("Test-passphrase1!");
516        let key_alias = provision_identity(&ctx);
517
518        let config = RotationConfig {
519            repo_path: std::path::PathBuf::from("/unused"),
520            identity_key_alias: Some(key_alias.clone()),
521            next_key_alias: None,
522        };
523
524        let (identity, prefix, alias) = resolve_rotation_context(&config, &ctx).unwrap();
525        assert!(identity.controller_did.as_str().starts_with("did:keri:"));
526        assert_eq!(
527            prefix.as_str(),
528            identity
529                .controller_did
530                .as_str()
531                .strip_prefix("did:keri:")
532                .unwrap()
533        );
534        assert_eq!(alias, key_alias);
535    }
536
537    #[test]
538    fn resolve_rotation_context_auto_discovers_alias() {
539        let ctx = fake_ctx("Test-passphrase1!");
540        let _key_alias = provision_identity(&ctx);
541
542        let config = RotationConfig {
543            repo_path: std::path::PathBuf::from("/unused"),
544            identity_key_alias: None,
545            next_key_alias: None,
546        };
547
548        let (_identity, _prefix, alias) = resolve_rotation_context(&config, &ctx).unwrap();
549        assert!(!alias.contains("--next-"));
550    }
551
552    #[test]
553    fn resolve_rotation_context_missing_identity_returns_error() {
554        let ctx = fake_ctx("unused");
555
556        let config = RotationConfig {
557            repo_path: std::path::PathBuf::from("/unused"),
558            identity_key_alias: Some(KeyAlias::new_unchecked("any")),
559            next_key_alias: None,
560        };
561
562        let result = resolve_rotation_context(&config, &ctx);
563        assert!(matches!(
564            result,
565            Err(RotationError::IdentityNotFound { .. })
566        ));
567    }
568
569    // -- retrieve_precommitted_key --
570
571    #[test]
572    fn retrieve_precommitted_key_succeeds_after_setup() {
573        let ctx = fake_ctx("Test-passphrase1!");
574        let key_alias = provision_identity(&ctx);
575
576        let config = RotationConfig {
577            repo_path: std::path::PathBuf::from("/unused"),
578            identity_key_alias: Some(key_alias.clone()),
579            next_key_alias: None,
580        };
581
582        let (identity, prefix, _) = resolve_rotation_context(&config, &ctx).unwrap();
583        let state = ctx.registry.get_key_state(&prefix).unwrap();
584
585        let (decrypted, old_alias) =
586            retrieve_precommitted_key(&identity.controller_did, &key_alias, &state, &ctx).unwrap();
587
588        assert!(!decrypted.is_empty());
589        assert!(old_alias.contains("--next-"));
590    }
591
592    #[test]
593    fn retrieve_precommitted_key_wrong_did_returns_error() {
594        let ctx = fake_ctx("Test-passphrase1!");
595        let key_alias = provision_identity(&ctx);
596
597        let config = RotationConfig {
598            repo_path: std::path::PathBuf::from("/unused"),
599            identity_key_alias: Some(key_alias.clone()),
600            next_key_alias: None,
601        };
602
603        let (_, prefix, _) = resolve_rotation_context(&config, &ctx).unwrap();
604        let state = ctx.registry.get_key_state(&prefix).unwrap();
605        let wrong_did = IdentityDID::new_unchecked("did:keri:EWrongDid".to_string());
606
607        let result = retrieve_precommitted_key(&wrong_did, &key_alias, &state, &ctx);
608        assert!(matches!(result, Err(RotationError::RotationFailed(_))));
609    }
610
611    #[test]
612    fn retrieve_precommitted_key_missing_key_returns_error() {
613        let ctx = fake_ctx("Test-passphrase1!");
614
615        let did = IdentityDID::new_unchecked("did:keri:Etest".to_string());
616        let state = KeyState {
617            prefix: Prefix::new_unchecked("Etest".to_string()),
618            current_keys: vec![],
619            next_commitment: vec![],
620            sequence: 999,
621            last_event_said: Said::default(),
622            is_abandoned: false,
623            threshold: 1,
624            next_threshold: 1,
625        };
626
627        let result = retrieve_precommitted_key(
628            &did,
629            &KeyAlias::new_unchecked("nonexistent-alias"),
630            &state,
631            &ctx,
632        );
633        assert!(matches!(result, Err(RotationError::KeyNotFound(_))));
634    }
635
636    // -- generate_rotation_keys --
637
638    #[test]
639    fn generate_rotation_keys_produces_valid_event() {
640        let ctx = fake_ctx("Test-passphrase1!");
641        let key_alias = provision_identity(&ctx);
642
643        let config = RotationConfig {
644            repo_path: std::path::PathBuf::from("/unused"),
645            identity_key_alias: Some(key_alias.clone()),
646            next_key_alias: None,
647        };
648
649        let (identity, prefix, _) = resolve_rotation_context(&config, &ctx).unwrap();
650        let state = ctx.registry.get_key_state(&prefix).unwrap();
651        let (decrypted, _) =
652            retrieve_precommitted_key(&identity.controller_did, &key_alias, &state, &ctx).unwrap();
653
654        let (rot, new_next_pkcs8) = generate_rotation_keys(&identity, &state, &decrypted).unwrap();
655
656        assert_eq!(rot.s, KeriSequence::new(state.sequence + 1));
657        assert_eq!(rot.i, prefix);
658        assert!(!rot.d.is_empty());
659        assert!(!rot.x.is_empty());
660        assert!(!new_next_pkcs8.as_ref().is_empty());
661    }
662
663    // -- finalize_rotation_storage --
664
665    #[test]
666    fn finalize_rotation_storage_persists_keys() {
667        let ctx = fake_ctx("Test-passphrase1!");
668        let key_alias = provision_identity(&ctx);
669
670        let config = RotationConfig {
671            repo_path: std::path::PathBuf::from("/unused"),
672            identity_key_alias: Some(key_alias.clone()),
673            next_key_alias: None,
674        };
675
676        let (identity, prefix, _) = resolve_rotation_context(&config, &ctx).unwrap();
677        let state = ctx.registry.get_key_state(&prefix).unwrap();
678        let (decrypted, old_next_alias) =
679            retrieve_precommitted_key(&identity.controller_did, &key_alias, &state, &ctx).unwrap();
680        let (rot, new_next_pkcs8) = generate_rotation_keys(&identity, &state, &decrypted).unwrap();
681
682        let rotated_alias = KeyAlias::new_unchecked("rotated-key");
683        let result = finalize_rotation_storage(
684            FinalizeParams {
685                did: &identity.controller_did,
686                prefix: &prefix,
687                next_alias: &rotated_alias,
688                old_next_alias: &old_next_alias,
689                current_pkcs8: &decrypted,
690                new_next_pkcs8: new_next_pkcs8.as_ref(),
691                rot: &rot,
692                state: &state,
693            },
694            &ctx,
695        );
696
697        assert!(
698            result.is_ok(),
699            "finalize_rotation_storage failed: {:?}",
700            result
701        );
702
703        let (loaded_did, _) = ctx
704            .key_storage
705            .load_key(&KeyAlias::new_unchecked("rotated-key"))
706            .unwrap();
707        assert_eq!(loaded_did, identity.controller_did);
708
709        let new_sequence = state.sequence + 1;
710        let next_key_alias = format!("rotated-key--next-{}", new_sequence);
711        let (loaded_next_did, _) = ctx
712            .key_storage
713            .load_key(&KeyAlias::new_unchecked(&next_key_alias))
714            .unwrap();
715        assert_eq!(loaded_next_did, identity.controller_did);
716    }
717
718    #[test]
719    fn finalize_rotation_storage_rejects_mismatched_passphrases() {
720        use std::sync::atomic::{AtomicU32, Ordering};
721
722        struct AlternatingProvider {
723            call_count: AtomicU32,
724        }
725
726        impl PassphraseProvider for AlternatingProvider {
727            fn get_passphrase(
728                &self,
729                _prompt: &str,
730            ) -> Result<zeroize::Zeroizing<String>, auths_core::AgentError> {
731                let n = self.call_count.fetch_add(1, Ordering::SeqCst);
732                if n.is_multiple_of(2) {
733                    Ok(zeroize::Zeroizing::new("pass-a".to_string()))
734                } else {
735                    Ok(zeroize::Zeroizing::new("pass-b".to_string()))
736                }
737            }
738        }
739
740        let prefix = Prefix::new_unchecked("ETestMismatch".to_string());
741        let did = IdentityDID::new_unchecked("did:keri:ETestMismatch".to_string());
742
743        let state = KeyState {
744            prefix: prefix.clone(),
745            current_keys: vec!["D_key".to_string()],
746            next_commitment: vec!["hash".to_string()],
747            sequence: 0,
748            last_event_said: Said::new_unchecked("EPrior".to_string()),
749            is_abandoned: false,
750            threshold: 1,
751            next_threshold: 1,
752        };
753
754        let rng = SystemRandom::new();
755        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
756
757        let dummy_rot = RotEvent {
758            v: KERI_VERSION.to_string(),
759            d: Said::new_unchecked("E_dummy".to_string()),
760            i: prefix.clone(),
761            s: KeriSequence::new(1),
762            p: Said::default(),
763            kt: "1".to_string(),
764            k: vec![],
765            nt: "1".to_string(),
766            n: vec![],
767            bt: "0".to_string(),
768            b: vec![],
769            a: vec![],
770            x: String::new(),
771        };
772
773        let ctx =
774            AuthsContext::builder()
775                .registry(
776                    Arc::new(FakeRegistryBackend::new()) as Arc<dyn RegistryBackend + Send + Sync>
777                )
778                .key_storage(Arc::new(MemoryKeychainHandle))
779                .clock(Arc::new(SystemClock))
780                .identity_storage(
781                    Arc::new(FakeIdentityStorage::new()) as Arc<dyn IdentityStorage + Send + Sync>
782                )
783                .attestation_sink(
784                    Arc::new(FakeAttestationSink::new()) as Arc<dyn AttestationSink + Send + Sync>
785                )
786                .attestation_source(Arc::new(FakeAttestationSource::new())
787                    as Arc<dyn AttestationSource + Send + Sync>)
788                .passphrase_provider(Arc::new(AlternatingProvider {
789                    call_count: AtomicU32::new(0),
790                })
791                    as Arc<dyn PassphraseProvider + Send + Sync>)
792                .build()
793                .unwrap();
794
795        let test_alias = KeyAlias::new_unchecked("test-alias");
796        let old_alias = KeyAlias::new_unchecked("old-alias");
797        let result = finalize_rotation_storage(
798            FinalizeParams {
799                did: &did,
800                prefix: &prefix,
801                next_alias: &test_alias,
802                old_next_alias: &old_alias,
803                current_pkcs8: pkcs8.as_ref(),
804                new_next_pkcs8: pkcs8.as_ref(),
805                rot: &dummy_rot,
806                state: &state,
807            },
808            &ctx,
809        );
810
811        assert!(
812            matches!(result, Err(RotationError::RotationFailed(ref msg)) if msg.contains("passphrases do not match")),
813            "Expected passphrase mismatch error, got: {:?}",
814            result
815        );
816    }
817}