Skip to main content

auths_id/identity/
rotate.rs

1//! KERI key rotation wrapper.
2//!
3//! This module provides high-level wrappers around the KERI rotation
4//! functionality, handling key storage and passphrase management.
5//!
6//! Two backends are supported:
7//! - [`rotate_keri_identity`]: GitKel-based storage (legacy, per-identity refs)
8//! - [`rotate_registry_identity`]: Packed registry storage (single `refs/auths/registry` ref)
9
10use git2::Repository;
11use ring::rand::SystemRandom;
12use ring::signature::{Ed25519KeyPair, KeyPair};
13use std::path::Path;
14
15use auths_crypto::Pkcs8Der;
16
17use crate::error::InitError;
18use crate::identity::helpers::load_keypair_from_der_or_seed;
19use crate::keri::{
20    CesrKey, Event, GitKel, KeriSequence, Prefix, RotEvent, Said, Threshold, VersionString,
21    rotate_keys, serialize_for_signing, validate_kel,
22};
23use std::sync::Arc;
24
25use crate::storage::layout::StorageLayoutConfig;
26use crate::storage::registry::RegistryBackend;
27use crate::witness_config::WitnessConfig;
28use auths_core::crypto::said::{compute_next_commitment, verify_commitment};
29use auths_core::crypto::signer::{decrypt_keypair, encrypt_keypair};
30use auths_core::signing::PassphraseProvider;
31use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyRole, KeyStorage};
32use auths_keri::compute_said;
33
34/// Result of a rotation operation with keychain-specific info.
35pub struct RotationKeyInfo {
36    /// The sequence number after rotation
37    pub sequence: u128,
38    /// The new current keypair (PKCS8 DER encoded, zeroed on drop)
39    pub new_current_pkcs8: Pkcs8Der,
40    /// The new next keypair (PKCS8 DER encoded, zeroed on drop)
41    pub new_next_pkcs8: Pkcs8Der,
42}
43
44/// Shape of a rotation request: adds, removes, threshold changes.
45///
46/// A `RotationShape::default()` is a key-set-preserving rotation: same
47/// key count and thresholds, only the pre-committed next keys rotate in.
48/// Non-default variants drive multi-device topology changes (new device,
49/// removed device, threshold change).
50#[derive(Debug, Clone, Default)]
51pub struct RotationShape {
52    /// Curves for newly-added device slots. One fresh keypair is generated
53    /// per entry and appended to the new `k` list.
54    pub add_devices: Vec<auths_crypto::CurveType>,
55    /// Indices into the prior `n` commitment list to exclude from the new
56    /// `k` list. Requires CESR indexed-signature support (tracked for a
57    /// future pass); current validator rejects asymmetric rotations.
58    pub remove_indices: Vec<u32>,
59    /// New signing threshold. `None` keeps the prior `kt`.
60    pub new_kt: Option<Threshold>,
61    /// New next-rotation threshold. `None` keeps the prior `nt`.
62    pub new_nt: Option<Threshold>,
63}
64
65/// Rotates a KERI identity using the GitKel backend.
66///
67/// Reads and writes the KEL via per-identity Git refs (`refs/did/keri/{prefix}/kel`).
68/// Use [`rotate_registry_identity`] for the packed registry backend.
69///
70/// # Arguments
71/// * `repo_path` - Path to the Git repository containing the KEL.
72/// * `current_alias` - Keychain alias for the current signing keypair.
73/// * `next_alias` - Keychain alias to store the **new** current keypair under (after rotation).
74/// * `passphrase_provider` - Service to get passphrases for key decryption/re-encryption.
75/// * `_config` - Storage layout configuration (unused but kept for API compatibility).
76/// * `keychain` - Keychain storage implementation.
77///
78/// # Returns
79/// * `Result<RotationKeyInfo>` - Information about the rotation if successful.
80#[allow(clippy::too_many_arguments)]
81pub fn rotate_keri_identity(
82    repo_path: &Path,
83    current_alias: &KeyAlias,
84    next_alias: &KeyAlias,
85    passphrase_provider: &dyn PassphraseProvider,
86    _config: &StorageLayoutConfig,
87    keychain: &(dyn KeyStorage + Send + Sync),
88    witness_config: Option<&WitnessConfig>,
89    now: chrono::DateTime<chrono::Utc>,
90) -> Result<RotationKeyInfo, InitError> {
91    let repo = Repository::open(repo_path)?;
92
93    let (did, _role, _encrypted_current) = keychain.load_key(current_alias)?;
94
95    if keychain.is_hardware_backend() {
96        return Err(InitError::InvalidData(
97            "Rotation requires a software-backed key; current key is hardware-backed \
98             (Secure Enclave). Rotate by initializing a new identity."
99                .into(),
100        ));
101    }
102
103    let prefix = did.as_str().strip_prefix("did:keri:").ok_or_else(|| {
104        InitError::InvalidData(format!("Invalid DID format, expected 'did:keri:': {}", did))
105    })?;
106
107    let kel = GitKel::new(&repo, prefix);
108    let events = kel
109        .get_events()
110        .map_err(|e| InitError::Keri(e.to_string()))?;
111    let state = validate_kel(&events).map_err(|e| InitError::Keri(e.to_string()))?;
112
113    let derived_next_alias = KeyAlias::new_unchecked(format!(
114        "{}--next-{}",
115        current_alias, state.last_establishment_sequence
116    ));
117
118    let (did_check, _role, encrypted_next) = keychain.load_key(&derived_next_alias)?;
119
120    if did != did_check {
121        return Err(InitError::InvalidData(format!(
122            "DID mismatch for pre-committed key '{}': expected {}, found {}",
123            derived_next_alias, did, did_check
124        )));
125    }
126
127    let next_pass = passphrase_provider.get_passphrase(&format!(
128        "Enter passphrase for pre-committed key '{}':",
129        derived_next_alias
130    ))?;
131    let decrypted_next_pkcs8 =
132        Pkcs8Der::new(decrypt_keypair(&encrypted_next, &next_pass)?.to_vec());
133
134    let rotation_result = rotate_keys(
135        &repo,
136        &Prefix::new_unchecked(prefix.to_string()),
137        &decrypted_next_pkcs8,
138        witness_config,
139        now,
140    )
141    .map_err(|e| InitError::Keri(e.to_string()))?;
142
143    let new_pass = passphrase_provider.get_passphrase(&format!(
144        "Create passphrase for new key alias '{}':",
145        next_alias
146    ))?;
147    let confirm_pass =
148        passphrase_provider.get_passphrase(&format!("Confirm passphrase for '{}':", next_alias))?;
149    if new_pass != confirm_pass {
150        return Err(InitError::InvalidData(format!(
151            "Passphrases do not match for alias '{}'",
152            next_alias
153        )));
154    }
155
156    let encrypted_new_current = encrypt_keypair(decrypted_next_pkcs8.as_ref(), &new_pass)?;
157    keychain.store_key(next_alias, &did, KeyRole::Primary, &encrypted_new_current)?;
158
159    // pass through the curve-tagged PKCS8 blob directly.
160    let encrypted_future =
161        encrypt_keypair(rotation_result.new_next_keypair_pkcs8.as_ref(), &new_pass)?;
162
163    let future_key_alias =
164        KeyAlias::new_unchecked(format!("{}--next-{}", next_alias, rotation_result.sequence));
165    keychain.store_key(
166        &future_key_alias,
167        &did,
168        KeyRole::NextRotation,
169        &encrypted_future,
170    )?;
171
172    let _ = keychain.delete_key(&derived_next_alias);
173    log::debug!("Cleaned up pre-committed key: {}", derived_next_alias);
174
175    Ok(RotationKeyInfo {
176        sequence: rotation_result.sequence,
177        new_current_pkcs8: rotation_result.new_current_keypair_pkcs8,
178        new_next_pkcs8: rotation_result.new_next_keypair_pkcs8,
179    })
180}
181
182/// Rotates a KERI identity using the packed registry backend.
183///
184/// Reads and writes the KEL via `refs/auths/registry` (packed single-ref storage).
185/// Use [`rotate_keri_identity`] for the legacy GitKel backend.
186///
187/// Args:
188/// * `backend` - The registry backend holding the identity KEL.
189/// * `current_alias` - Keychain alias for the current signing keypair.
190/// * `next_alias` - Keychain alias to store the **new** current keypair under (after rotation).
191/// * `passphrase_provider` - Service to get passphrases for key decryption/re-encryption.
192/// * `_config` - Storage layout configuration (unused but kept for API compatibility).
193/// * `keychain` - Keychain storage implementation.
194/// * `witness_config` - Optional witness configuration.
195///
196/// Usage:
197/// ```ignore
198/// let info = rotate_registry_identity(Arc::new(my_backend), "current", "next", &provider, &config, &keychain, None)?;
199/// ```
200#[allow(clippy::too_many_lines)]
201pub fn rotate_registry_identity(
202    backend: Arc<dyn RegistryBackend + Send + Sync>,
203    current_alias: &KeyAlias,
204    next_alias: &KeyAlias,
205    passphrase_provider: &dyn PassphraseProvider,
206    _config: &StorageLayoutConfig,
207    keychain: &(dyn KeyStorage + Send + Sync),
208    witness_config: Option<&WitnessConfig>,
209) -> Result<RotationKeyInfo, InitError> {
210    let rng = SystemRandom::new();
211
212    let (did, _role, _encrypted_current) = keychain.load_key(current_alias)?;
213
214    if keychain.is_hardware_backend() {
215        return Err(InitError::InvalidData(
216            "Rotation requires a software-backed key; current key is hardware-backed \
217             (Secure Enclave). Rotate by initializing a new identity."
218                .into(),
219        ));
220    }
221
222    let prefix_str = did.as_str().strip_prefix("did:keri:").ok_or_else(|| {
223        InitError::InvalidData(format!("Invalid DID format, expected 'did:keri:': {}", did))
224    })?;
225    let prefix = Prefix::new_unchecked(prefix_str.to_string());
226
227    let state = backend
228        .get_key_state(&prefix)
229        .map_err(|e| InitError::Registry(e.to_string()))?;
230
231    let derived_next_alias = KeyAlias::new_unchecked(format!(
232        "{}--next-{}",
233        current_alias, state.last_establishment_sequence
234    ));
235
236    let (did_check, _role, encrypted_next) = keychain.load_key(&derived_next_alias)?;
237
238    if did != did_check {
239        return Err(InitError::InvalidData(format!(
240            "DID mismatch for pre-committed key '{}': expected {}, found {}",
241            derived_next_alias, did, did_check
242        )));
243    }
244
245    let next_pass = passphrase_provider.get_passphrase(&format!(
246        "Enter passphrase for pre-committed key '{}':",
247        derived_next_alias
248    ))?;
249    let decrypted_next_pkcs8 =
250        Pkcs8Der::new(decrypt_keypair(&encrypted_next, &next_pass)?.to_vec());
251
252    if !state.can_rotate() {
253        return Err(InitError::InvalidData(
254            "Identity is abandoned (cannot rotate)".into(),
255        ));
256    }
257
258    let next_keypair = load_keypair_from_der_or_seed(decrypted_next_pkcs8.as_ref())?;
259
260    #[allow(clippy::expect_used)] // INVARIANT: ring Ed25519 public key is always 32 bytes
261    let next_verkey = auths_keri::KeriPublicKey::ed25519(next_keypair.public_key().as_ref())
262        .expect("ring Ed25519 public key is 32 bytes");
263    if !verify_commitment(&next_verkey, &state.next_commitment[0]) {
264        return Err(InitError::InvalidData(
265            "Commitment mismatch: next key does not match previous commitment".into(),
266        ));
267    }
268
269    let new_next_pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
270        .map_err(|e| InitError::Crypto(format!("Key generation failed: {}", e)))?;
271    let new_next_keypair = Ed25519KeyPair::from_pkcs8(new_next_pkcs8.as_ref())
272        .map_err(|e| InitError::Crypto(format!("Key generation failed: {}", e)))?;
273
274    #[allow(clippy::expect_used)]
275    // INVARIANT: ring Ed25519 public key is always 32 bytes; cesride encode of a valid 32-byte verkey is infallible
276    let new_current_pub_encoded =
277        auths_keri::KeriPublicKey::ed25519(next_keypair.public_key().as_ref())
278            .expect("ring Ed25519 public key is 32 bytes")
279            .to_qb64()
280            .expect("cesride verkey encode is infallible");
281    #[allow(clippy::expect_used)] // INVARIANT: ring Ed25519 public key is always 32 bytes
282    let new_next_verkey =
283        auths_keri::KeriPublicKey::ed25519(new_next_keypair.public_key().as_ref())
284            .expect("ring Ed25519 public key is 32 bytes");
285    let new_next_commitment = compute_next_commitment(&new_next_verkey);
286
287    let bt = match witness_config {
288        Some(cfg) if cfg.is_enabled() => Threshold::Simple(cfg.threshold as u64),
289        _ => Threshold::Simple(0),
290    };
291
292    let new_sequence = state.sequence + 1;
293    let mut rot = RotEvent {
294        v: VersionString::placeholder(),
295        d: Said::default(),
296        i: prefix.clone(),
297        s: KeriSequence::new(new_sequence),
298        p: state.last_event_said.clone(),
299        kt: Threshold::Simple(1),
300        k: vec![CesrKey::new_unchecked(new_current_pub_encoded)],
301        nt: Threshold::Simple(1),
302        n: vec![new_next_commitment],
303        bt,
304        br: vec![],
305        ba: vec![],
306        c: vec![],
307        a: vec![],
308    };
309
310    let rot_value = serde_json::to_value(Event::Rot(rot.clone()))
311        .map_err(|e| InitError::Keri(format!("Serialization failed: {}", e)))?;
312    rot.d = compute_said(&rot_value)
313        .map_err(|e| InitError::Keri(format!("SAID computation failed: {}", e)))?;
314
315    let canonical = serialize_for_signing(&Event::Rot(rot.clone()))
316        .map_err(|e| InitError::Keri(e.to_string()))?;
317    let sig = next_keypair.sign(&canonical);
318    let attachment = auths_keri::serialize_attachment(&[auths_keri::IndexedSignature {
319        index: 0,
320        prior_index: None,
321        sig: sig.as_ref().to_vec(),
322    }])
323    .map_err(|e| InitError::Keri(format!("attachment serialization: {e}")))?;
324
325    backend
326        .append_signed_event(&prefix, &Event::Rot(rot), &attachment)
327        .map_err(|e| InitError::Registry(e.to_string()))?;
328
329    store_rotated_keys(
330        keychain,
331        passphrase_provider,
332        &did,
333        next_alias,
334        &derived_next_alias,
335        new_sequence,
336        decrypted_next_pkcs8.as_ref(),
337        new_next_pkcs8.as_ref(),
338    )?;
339
340    Ok(RotationKeyInfo {
341        sequence: new_sequence,
342        new_current_pkcs8: decrypted_next_pkcs8,
343        new_next_pkcs8: Pkcs8Der::new(new_next_pkcs8.as_ref()),
344    })
345}
346
347/// Rotate a KERI identity with a multi-device shape (registry backend).
348///
349/// Reveals each prior-committed next key at the matching new `k[i]` slot,
350/// optionally appends newly-generated device keypairs from `shape.add_devices`,
351/// and optionally applies new `kt`/`nt` thresholds. Produces a signed `rot`
352/// event appended to the registry KEL.
353///
354/// Keychain convention: prior next keys are loaded from
355/// `{current_alias}--next-{prev_seq}-{idx}`; new keys are stored at
356/// `{next_alias}--{idx}` (current) and `{next_alias}--next-{new_seq}-{idx}`
357/// (next).
358///
359/// `shape.remove_indices` authors a dual-index shrink rotation (the validator
360/// binds each surviving signature to the prior commitment it reveals). Note:
361/// multi-device membership now uses KERI delegation (`keri::delegation`); this
362/// shared-`k` path is retained for single-host multi-slot identities.
363#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
364pub fn rotate_registry_identity_multi(
365    backend: Arc<dyn RegistryBackend + Send + Sync>,
366    current_alias: &KeyAlias,
367    next_alias: &KeyAlias,
368    passphrase_provider: &dyn PassphraseProvider,
369    _config: &StorageLayoutConfig,
370    keychain: &(dyn KeyStorage + Send + Sync),
371    witness_config: Option<&WitnessConfig>,
372    shape: RotationShape,
373) -> Result<RotationKeyInfo, InitError> {
374    // Load the base DID from the first current slot and verify the registry.
375    let first_cur = KeyAlias::new_unchecked(format!("{}--{}", current_alias, 0));
376    let (did, _role, _encrypted) = keychain
377        .load_key(&first_cur)
378        .or_else(|_| keychain.load_key(current_alias))?;
379
380    if keychain.is_hardware_backend() {
381        return Err(InitError::InvalidData(
382            "Rotation requires software-backed keys; current slot is hardware-backed.".to_string(),
383        ));
384    }
385
386    let prefix_str = did.as_str().strip_prefix("did:keri:").ok_or_else(|| {
387        InitError::InvalidData(format!("Invalid DID format, expected 'did:keri:': {}", did))
388    })?;
389    let prefix = Prefix::new_unchecked(prefix_str.to_string());
390
391    let state = backend
392        .get_key_state(&prefix)
393        .map_err(|e| InitError::Registry(e.to_string()))?;
394
395    if !state.can_rotate() {
396        return Err(InitError::InvalidData(
397            "Identity is abandoned (cannot rotate)".to_string(),
398        ));
399    }
400
401    let prior_key_count = state.current_keys.len();
402
403    // Validate removal targets (dedup, in range). At least one prior controller
404    // must survive to authorise the rotation — a surviving signer reveals a prior
405    // commitment (dual-index). Replacing the whole set is the swap/recovery path.
406    let mut remove: Vec<usize> = shape.remove_indices.iter().map(|&i| i as usize).collect();
407    remove.sort_unstable();
408    remove.dedup();
409    if let Some(&bad) = remove.iter().find(|&&i| i >= prior_key_count) {
410        return Err(InitError::InvalidData(format!(
411            "remove index {bad} out of range (prior controller count {prior_key_count})"
412        )));
413    }
414    let surviving_count = prior_key_count - remove.len();
415    if surviving_count == 0 {
416        return Err(InitError::InvalidData(
417            "rotation must retain at least one prior controller to authorise it".to_string(),
418        ));
419    }
420    let new_key_count = surviving_count + shape.add_devices.len();
421
422    let new_kt = shape.new_kt.unwrap_or_else(|| state.threshold.clone());
423    let new_nt = shape.new_nt.unwrap_or_else(|| state.next_threshold.clone());
424
425    crate::keri::inception::validate_threshold_for_key_count(&new_kt, new_key_count)
426        .map_err(|e| InitError::InvalidData(e.to_string()))?;
427    crate::keri::inception::validate_threshold_for_key_count(&new_nt, new_key_count)
428        .map_err(|e| InitError::InvalidData(e.to_string()))?;
429
430    // Decrypt each SURVIVING prior-committed next key in slot order. These become
431    // the new current keys at indices 0..surviving_count. `prior_indices[new_i]`
432    // records the prior `n[]` slot each survivor reveals (its dual-index ondex).
433    let mut new_current_pkcs8s: Vec<Pkcs8Der> = Vec::with_capacity(new_key_count);
434    let mut new_current_pubs: Vec<auths_keri::KeriPublicKey> = Vec::with_capacity(new_key_count);
435    let mut prior_next_aliases: Vec<KeyAlias> = Vec::with_capacity(prior_key_count);
436    let mut prior_indices: Vec<u32> = Vec::with_capacity(surviving_count);
437
438    let next_pass = passphrase_provider.get_passphrase(&format!(
439        "Enter passphrase for pre-committed keys under alias '{}':",
440        current_alias
441    ))?;
442
443    for idx in 0..prior_key_count {
444        let alias = KeyAlias::new_unchecked(format!(
445            "{}--next-{}-{}",
446            current_alias, state.sequence, idx
447        ));
448        if remove.contains(&idx) {
449            // Removed controller: its pre-committed next key is dropped from the
450            // new set. Keep the alias only so the cleanup pass deletes it.
451            prior_next_aliases.push(alias);
452            continue;
453        }
454        let (did_check, _role, encrypted) = keychain.load_key(&alias)?;
455        if did_check != did {
456            return Err(InitError::InvalidData(format!(
457                "DID mismatch for pre-committed key '{}'",
458                alias
459            )));
460        }
461        let pkcs8 = Pkcs8Der::new(decrypt_keypair(&encrypted, &next_pass)?.to_vec());
462        let keypair = load_keypair_from_der_or_seed(pkcs8.as_ref())?;
463        #[allow(clippy::expect_used)] // INVARIANT: ring Ed25519 public key is always 32 bytes
464        let next_verkey = auths_keri::KeriPublicKey::ed25519(keypair.public_key().as_ref())
465            .expect("ring Ed25519 public key is 32 bytes");
466        if !verify_commitment(&next_verkey, &state.next_commitment[idx]) {
467            return Err(InitError::InvalidData(format!(
468                "Commitment mismatch at slot {idx}: next key does not match previous commitment"
469            )));
470        }
471        new_current_pubs.push(next_verkey);
472        new_current_pkcs8s.push(pkcs8);
473        prior_indices.push(idx as u32);
474        prior_next_aliases.push(alias);
475    }
476
477    // Generate added device keypairs and append to the new current set (a pure
478    // removal adds none — skip, as the generator rejects an empty curve list).
479    if !shape.add_devices.is_empty() {
480        let added = crate::keri::inception::generate_keypairs_for_init(&shape.add_devices)
481            .map_err(|e| InitError::Crypto(e.to_string()))?;
482        for kp in &added {
483            new_current_pubs.push(kp.verkey());
484            new_current_pkcs8s.push(kp.pkcs8.clone());
485        }
486    }
487
488    // Generate a fresh next keypair per new slot.
489    let new_next_curves: Vec<auths_crypto::CurveType> = (0..new_key_count)
490        .map(|_| auths_crypto::CurveType::P256)
491        .collect();
492    let new_next_kps = crate::keri::inception::generate_keypairs_for_init(&new_next_curves)
493        .map_err(|e| InitError::Crypto(e.to_string()))?;
494
495    let k: Vec<CesrKey> = new_current_pubs
496        .iter()
497        .map(|vk| {
498            vk.to_qb64()
499                .map(CesrKey::new_unchecked)
500                .map_err(|e| InitError::InvalidData(e.to_string()))
501        })
502        .collect::<Result<_, _>>()?;
503    let n: Vec<Said> = new_next_kps
504        .iter()
505        .map(|kp| compute_next_commitment(&kp.verkey()))
506        .collect();
507
508    let bt = match witness_config {
509        Some(cfg) if cfg.is_enabled() => Threshold::Simple(cfg.threshold as u64),
510        _ => Threshold::Simple(0),
511    };
512
513    let new_sequence = state.sequence + 1;
514    let mut rot = RotEvent {
515        v: VersionString::placeholder(),
516        d: Said::default(),
517        i: prefix.clone(),
518        s: KeriSequence::new(new_sequence),
519        p: state.last_event_said.clone(),
520        kt: new_kt,
521        k,
522        nt: new_nt,
523        n,
524        bt,
525        br: vec![],
526        ba: vec![],
527        c: vec![],
528        a: vec![],
529    };
530
531    let rot_value = serde_json::to_value(Event::Rot(rot.clone()))
532        .map_err(|e| InitError::Keri(format!("Serialization failed: {}", e)))?;
533    rot.d = compute_said(&rot_value)
534        .map_err(|e| InitError::Keri(format!("SAID computation failed: {}", e)))?;
535
536    // Sign with surviving slot 0's newly-revealed key (kt=1). Its `prior_index`
537    // is the prior `n[]` slot it reveals — for a shrink this drives the dual-index
538    // CESR siger so the validator can bind it; for a same-cardinality rotation it
539    // equals the index and stays byte-identical to the legacy single-index form.
540    // Multi-sig aggregation is the signing-workflow module's job.
541    let canonical = serialize_for_signing(&Event::Rot(rot.clone()))
542        .map_err(|e| InitError::Keri(e.to_string()))?;
543    let signer_keypair = load_keypair_from_der_or_seed(new_current_pkcs8s[0].as_ref())?;
544    let sig = signer_keypair.sign(&canonical);
545    let attachment = auths_keri::serialize_attachment(&[auths_keri::IndexedSignature {
546        index: 0,
547        prior_index: prior_indices.first().copied(),
548        sig: sig.as_ref().to_vec(),
549    }])
550    .map_err(|e| InitError::Keri(format!("attachment serialization: {e}")))?;
551
552    backend
553        .append_signed_event(&prefix, &Event::Rot(rot), &attachment)
554        .map_err(|e| InitError::Registry(e.to_string()))?;
555
556    // Persist the new current + next keys under {next_alias}--{idx} and
557    // {next_alias}--next-{new_seq}-{idx}. Best-effort cleanup of prior
558    // pre-committed slots.
559    let new_pass = passphrase_provider.get_passphrase(&format!(
560        "Create passphrase for new key alias '{}':",
561        next_alias
562    ))?;
563    let confirm_pass =
564        passphrase_provider.get_passphrase(&format!("Confirm passphrase for '{}':", next_alias))?;
565    if new_pass != confirm_pass {
566        return Err(InitError::InvalidData(format!(
567            "Passphrases do not match for alias '{}'",
568            next_alias
569        )));
570    }
571
572    for (idx, cur_pkcs8) in new_current_pkcs8s.iter().enumerate() {
573        let slot_alias = KeyAlias::new_unchecked(format!("{}--{}", next_alias, idx));
574        let encrypted = encrypt_keypair(cur_pkcs8.as_ref(), &new_pass)?;
575        keychain.store_key(&slot_alias, &did, KeyRole::Primary, &encrypted)?;
576    }
577    for (idx, nxt_kp) in new_next_kps.iter().enumerate() {
578        let slot_alias =
579            KeyAlias::new_unchecked(format!("{}--next-{}-{}", next_alias, new_sequence, idx));
580        let encrypted = encrypt_keypair(nxt_kp.pkcs8.as_ref(), &new_pass)?;
581        keychain.store_key(&slot_alias, &did, KeyRole::NextRotation, &encrypted)?;
582    }
583    for alias in &prior_next_aliases {
584        let _ = keychain.delete_key(alias);
585    }
586
587    // Return info shaped around slot 0 for API compatibility with single-key
588    // callers; callers wanting the full multi-key material can regenerate from
589    // the keychain via the slot-indexed aliases above.
590    let new_next_pkcs8_bytes = new_next_kps[0].pkcs8.as_ref().to_vec();
591    Ok(RotationKeyInfo {
592        sequence: new_sequence,
593        new_current_pkcs8: new_current_pkcs8s
594            .into_iter()
595            .next()
596            .ok_or_else(|| InitError::Crypto("empty current keyset after rotation".to_string()))?,
597        new_next_pkcs8: Pkcs8Der::new(new_next_pkcs8_bytes),
598    })
599}
600
601#[allow(clippy::too_many_arguments)]
602fn store_rotated_keys(
603    keychain: &(dyn KeyStorage + Send + Sync),
604    passphrase_provider: &dyn PassphraseProvider,
605    did: &IdentityDID,
606    next_alias: &KeyAlias,
607    old_next_alias: &KeyAlias,
608    new_sequence: u128,
609    current_pkcs8: &[u8],
610    new_next_pkcs8: &[u8],
611) -> Result<(), InitError> {
612    let new_pass = passphrase_provider.get_passphrase(&format!(
613        "Create passphrase for new key alias '{}':",
614        next_alias
615    ))?;
616    let confirm_pass =
617        passphrase_provider.get_passphrase(&format!("Confirm passphrase for '{}':", next_alias))?;
618    if new_pass != confirm_pass {
619        return Err(InitError::InvalidData(format!(
620            "Passphrases do not match for alias '{}'",
621            next_alias
622        )));
623    }
624
625    let encrypted_new_current = encrypt_keypair(current_pkcs8, &new_pass)?;
626    keychain.store_key(next_alias, did, KeyRole::Primary, &encrypted_new_current)?;
627
628    // pass-through avoids the extract-then-re-encode silent-Ed25519 hazard.
629    let encrypted_future = encrypt_keypair(new_next_pkcs8, &new_pass)?;
630
631    let future_key_alias =
632        KeyAlias::new_unchecked(format!("{}--next-{}", next_alias, new_sequence));
633    keychain.store_key(
634        &future_key_alias,
635        did,
636        KeyRole::NextRotation,
637        &encrypted_future,
638    )?;
639
640    let _ = keychain.delete_key(old_next_alias);
641
642    Ok(())
643}