Skip to main content

auths_id/keri/
shared_kel.rs

1//! Shared identity KEL — one KEL whose controllers are the user's devices.
2//!
3//! The user's identity is a KEL whose `k` list enumerates the currently
4//! trusted device KEL DIDs. Pairing a new device = `rot` that appends a
5//! controller. Retiring a device = `rot` that drops one. Stolen-laptop
6//! recovery = swap (drop + add in the same rotation).
7//!
8//! **Controller identity model (load-bearing)**: controllers are device
9//! `did:keri:` prefixes, not raw verkeys. When the shared KEL's `rot`
10//! events record `k`, the bytes are the device's *current verkey at the
11//! time the rot was authored* — but the semantic identity of a
12//! controller across rotations is its `did:keri:`. Verifiers resolve a
13//! controller signature by (1) walking the device's own KEL to the
14//! current key and (2) checking the signature against that key.
15//!
16//! A device can rotate its own KEL without touching the shared KEL —
17//! verifiers re-resolve lazily. The shared KEL only changes when the
18//! *set* of controllers changes.
19//!
20//! Threshold is `kt=1` for now — any single controller can sign a
21//! shared-KEL rotation. Raising the threshold is a separate, later
22//! change.
23//!
24//! **Superseded (2026-06-03):** multi-device membership moved to KERI
25//! delegation (`keri::delegation`) — a device is a *delegated identifier* the
26//! root anchors via `ixn`, not a controller in a shared `k` list. One device
27//! cannot author a keripy-valid `rot` of a multi-device `k` (it can't reveal the
28//! other devices' pre-committed keys). This module is retained for reference;
29//! see `docs/architecture/device-model.md` "Design decision (2026-06-03)".
30
31use auths_core::storage::keychain::IdentityDID;
32use auths_crypto::CurveType;
33use auths_keri::KeriPublicKey;
34
35use super::{Prefix, Said};
36
37/// One controller of a shared identity KEL.
38///
39/// Binds the semantic identity (the device's `did:keri:` prefix) to the
40/// verkey currently committed under that identity. The verkey is what
41/// the shared KEL's `k` list stores at the moment the event is signed;
42/// subsequent device-side rotations do not require a shared-KEL update
43/// because verifiers re-resolve via the device's own KEL.
44#[derive(Debug, Clone)]
45pub struct ControllerDescriptor {
46    pub identity_did: IdentityDID,
47    pub current_verkey: KeriPublicKey,
48}
49
50/// Opaque handle describing a freshly-inceptioned shared identity KEL.
51#[derive(Debug, Clone)]
52pub struct SharedKelArtifacts {
53    pub prefix: Prefix,
54    pub inception_event_json: String,
55    pub controllers: Vec<ControllerDescriptor>,
56    /// Always 1 for now — any single controller can sign.
57    // TODO(stage-3): raise the threshold so compromising one device
58    // cannot rewrite the controller set.
59    pub kt: u32,
60}
61
62/// Opaque handle describing a `rot` event on the shared KEL.
63#[derive(Debug, Clone)]
64pub struct SharedKelRotArtifacts {
65    pub prefix: Prefix,
66    /// The SAID (`d` field) of the produced rotation event.
67    pub event_said: Said,
68    /// Serialized `rot` JSON, suitable for replication.
69    pub event_json: String,
70    /// New controller set after this rotation.
71    pub controllers: Vec<ControllerDescriptor>,
72}
73
74// ---------------------------------------------------------------------------
75// Add-only event authorship (symmetric growth, validator-safe today)
76// ---------------------------------------------------------------------------
77//
78// These helpers thin-wrap the existing multi-slot machinery in
79// `identity::initialize` + `identity::rotate` so the shared-KEL semantic
80// layer has a single public surface. (Superseded by `keri::delegation`.)
81
82/// Inception entry point for a shared identity KEL.
83///
84/// Thin wrapper over `initialize_registry_identity_multi`. The caller
85/// supplies a set of controller descriptors (one per paired device's
86/// current verkey + DID); the function inceptions the shared KEL under
87/// `refs/auths/shared-kel/<prefix>/*` with `kt=1` — any single
88/// controller may sign subsequent rotations.
89///
90/// This is a marker helper — it reuses the existing multi-slot
91/// inception code path. The caller is still responsible for providing
92/// a `RegistryBackend` + `KeyStorage` that knows how to persist under
93/// the shared-KEL namespace.
94pub fn incept_shared_kel_prepared(
95    controllers: &[ControllerDescriptor],
96) -> Result<Vec<ControllerDescriptor>, SharedKelError> {
97    if controllers.is_empty() {
98        return Err(SharedKelError::WouldOrphanIdentity);
99    }
100    Ok(controllers.to_vec())
101}
102
103/// Add-controller rotation entry point.
104///
105/// Builds the new controller set from the prior one plus `new` and
106/// returns it ready for persistence. Structurally a symmetric-growth
107/// rotation — the validator accepts it without indexed-signature
108/// support.
109pub fn rot_add_controller(
110    current: &[ControllerDescriptor],
111    new: &ControllerDescriptor,
112) -> Result<Vec<ControllerDescriptor>, SharedKelError> {
113    apply_shared_kel_change(current, &SharedKelChange::AddController { new })
114}
115
116/// Remove-controller rotation entry point.
117///
118/// Returns the new controller set with `target` dropped. The shrink `rot` is
119/// authored by [`crate::identity::rotate::rotate_registry_identity_multi`] with
120/// the target's slot in `remove_indices`; a surviving controller signs a
121/// dual-index rotation the validator now accepts. The orphan guard
122/// ([`SharedKelError::WouldOrphanIdentity`]) is enforced in
123/// [`apply_shared_kel_change`].
124pub fn rot_remove_controller(
125    current: &[ControllerDescriptor],
126    target: &IdentityDID,
127) -> Result<Vec<ControllerDescriptor>, SharedKelError> {
128    apply_shared_kel_change(current, &SharedKelChange::RemoveController { target })
129}
130
131/// Change requested by a shared-KEL rotation caller.
132///
133/// Callers pass DIDs; the rotation implementation resolves them to the
134/// current controller-list indices internally. Indices shift across
135/// rotations — exposing them in the public API would be a footgun.
136#[derive(Debug, Clone)]
137pub enum SharedKelChange<'a> {
138    /// Add a new controller (device) to the identity.
139    AddController { new: &'a ControllerDescriptor },
140    /// Remove the controller identified by DID. Errors if the DID is
141    /// not in the current controller set, or if removing it would
142    /// leave the identity with no controllers.
143    RemoveController { target: &'a IdentityDID },
144    /// Stolen-laptop recovery: drop `old` and add `new` in a single
145    /// rotation. Atomic — a verifier never sees an intermediate state
146    /// where the identity has one controller less.
147    SwapController {
148        old: &'a IdentityDID,
149        new: &'a ControllerDescriptor,
150    },
151}
152
153/// Errors specific to shared-KEL operations.
154#[derive(Debug, thiserror::Error)]
155pub enum SharedKelError {
156    /// The DID passed to `RemoveController` / `SwapController` is not a
157    /// current controller.
158    #[error("controller {0} is not in the current shared-KEL controller set")]
159    ControllerNotFound(String),
160    /// The requested rotation would leave the shared KEL with no
161    /// controllers — the identity would be orphaned.
162    #[error("rotation would orphan the identity (no remaining controllers)")]
163    WouldOrphanIdentity,
164    /// Construction of the rotation event failed.
165    #[error("rotation event construction failed: {0}")]
166    EventConstruction(String),
167}
168
169/// Resolve a controller DID to its index in the current controller
170/// list, returning [`SharedKelError::ControllerNotFound`] if absent.
171pub fn resolve_controller_index(
172    controllers: &[ControllerDescriptor],
173    target: &IdentityDID,
174) -> Result<usize, SharedKelError> {
175    controllers
176        .iter()
177        .position(|c| c.identity_did.as_str() == target.as_str())
178        .ok_or_else(|| SharedKelError::ControllerNotFound(target.as_str().to_string()))
179}
180
181/// Apply a [`SharedKelChange`] to the controller list, returning the
182/// new controller list or an error if the change is invalid.
183///
184/// Args:
185/// * `current`: Current controller set (from the prior KEL state).
186/// * `change`: The requested change.
187///
188/// Usage:
189/// ```ignore
190/// let next = apply_shared_kel_change(&current, &change)?;
191/// ```
192pub fn apply_shared_kel_change(
193    current: &[ControllerDescriptor],
194    change: &SharedKelChange<'_>,
195) -> Result<Vec<ControllerDescriptor>, SharedKelError> {
196    match change {
197        SharedKelChange::AddController { new } => {
198            let mut next: Vec<ControllerDescriptor> = current.to_vec();
199            next.push((*new).clone());
200            Ok(next)
201        }
202        SharedKelChange::RemoveController { target } => {
203            let idx = resolve_controller_index(current, target)?;
204            if current.len() <= 1 {
205                return Err(SharedKelError::WouldOrphanIdentity);
206            }
207            let mut next: Vec<ControllerDescriptor> = current.to_vec();
208            next.remove(idx);
209            Ok(next)
210        }
211        SharedKelChange::SwapController { old, new } => {
212            let idx = resolve_controller_index(current, old)?;
213            let mut next: Vec<ControllerDescriptor> = current.to_vec();
214            next[idx] = (*new).clone();
215            Ok(next)
216        }
217    }
218}
219
220/// Atomically replace one controller with another.
221///
222/// Convenience wrapper over [`apply_shared_kel_change`] that composes
223/// a single [`SharedKelChange::SwapController`] — a verifier never
224/// observes an intermediate state where the identity has fewer
225/// controllers than the prior rotation. Used by the stolen-laptop
226/// recovery flow, where the surviving controller signs one `rot`
227/// that drops the lost device's DID and adds the new device's DID.
228///
229/// The caller is still responsible for emitting the `rot` event to
230/// disk; this helper just computes the target controller set.
231///
232/// Args:
233/// * `current`: Current controller set (from prior KEL state).
234/// * `old_did`: DID to drop — must be in the current controller set.
235/// * `new`: Descriptor for the replacement device.
236///
237/// Usage:
238/// ```ignore
239/// let next = rot_swap_controller(&current, &old_mac_did, &new_mac_ctrl)?;
240/// ```
241pub fn rot_swap_controller(
242    current: &[ControllerDescriptor],
243    old_did: &IdentityDID,
244    new: &ControllerDescriptor,
245) -> Result<Vec<ControllerDescriptor>, SharedKelError> {
246    apply_shared_kel_change(
247        current,
248        &SharedKelChange::SwapController { old: old_did, new },
249    )
250}
251
252/// Controllers a device KEL may represent — helper for callers that
253/// need to bind their own device as a ControllerDescriptor without
254/// exposing the verkey encoding details of `auths-keri`.
255pub fn controller_from_parts(
256    did: IdentityDID,
257    verkey_bytes: Vec<u8>,
258    curve: CurveType,
259) -> Result<ControllerDescriptor, SharedKelError> {
260    let current_verkey = match curve {
261        CurveType::Ed25519 => {
262            let arr: [u8; 32] = verkey_bytes.as_slice().try_into().map_err(|_| {
263                SharedKelError::EventConstruction("Ed25519 verkey must be 32 bytes".into())
264            })?;
265            KeriPublicKey::Ed25519(arr)
266        }
267        CurveType::P256 => {
268            let arr: [u8; 33] = verkey_bytes.as_slice().try_into().map_err(|_| {
269                SharedKelError::EventConstruction(
270                    "P-256 verkey must be 33-byte compressed SEC1".into(),
271                )
272            })?;
273            KeriPublicKey::P256 {
274                key: arr,
275                transferable: true,
276            }
277        }
278    };
279    Ok(ControllerDescriptor {
280        identity_did: did,
281        current_verkey,
282    })
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    fn did(s: &str) -> IdentityDID {
290        #[allow(clippy::disallowed_methods)]
291        IdentityDID::new_unchecked(s.to_string())
292    }
293
294    fn controller(did_str: &str) -> ControllerDescriptor {
295        ControllerDescriptor {
296            identity_did: did(did_str),
297            current_verkey: KeriPublicKey::Ed25519([0u8; 32]),
298        }
299    }
300
301    #[test]
302    fn add_appends_controller() {
303        let current = vec![controller("did:keri:EAAAMac")];
304        let new = controller("did:keri:EBBBPhone");
305        let next = apply_shared_kel_change(&current, &SharedKelChange::AddController { new: &new })
306            .expect("add");
307        assert_eq!(next.len(), 2);
308        assert_eq!(next[1].identity_did.as_str(), "did:keri:EBBBPhone");
309    }
310
311    #[test]
312    fn remove_drops_named_controller() {
313        let current = vec![
314            controller("did:keri:EAAAMac"),
315            controller("did:keri:EBBBPhone"),
316        ];
317        let target = did("did:keri:EAAAMac");
318        let next = apply_shared_kel_change(
319            &current,
320            &SharedKelChange::RemoveController { target: &target },
321        )
322        .expect("remove");
323        assert_eq!(next.len(), 1);
324        assert_eq!(next[0].identity_did.as_str(), "did:keri:EBBBPhone");
325    }
326
327    #[test]
328    fn remove_of_missing_controller_errors() {
329        let current = vec![controller("did:keri:EAAAMac")];
330        let target = did("did:keri:EZZZMissing");
331        let err = apply_shared_kel_change(
332            &current,
333            &SharedKelChange::RemoveController { target: &target },
334        )
335        .unwrap_err();
336        assert!(matches!(err, SharedKelError::ControllerNotFound(_)));
337    }
338
339    #[test]
340    fn remove_of_last_controller_would_orphan_errors() {
341        let current = vec![controller("did:keri:EAAAMac")];
342        let target = did("did:keri:EAAAMac");
343        let err = apply_shared_kel_change(
344            &current,
345            &SharedKelChange::RemoveController { target: &target },
346        )
347        .unwrap_err();
348        assert!(matches!(err, SharedKelError::WouldOrphanIdentity));
349    }
350
351    #[test]
352    fn swap_is_atomic_controller_count_invariant() {
353        // Atomicity check: after swap, controller count must equal the
354        // prior count. A verifier must never observe an intermediate
355        // state with fewer controllers (what a naive remove-then-add
356        // would produce if it weren't composed into a single rot).
357        let current = vec![
358            controller("did:keri:EAAAMacOld"),
359            controller("did:keri:EBBBPhone"),
360        ];
361        let old = did("did:keri:EAAAMacOld");
362        let new = controller("did:keri:ECCCMacNew");
363        let next = rot_swap_controller(&current, &old, &new).expect("swap");
364        assert_eq!(
365            next.len(),
366            current.len(),
367            "controller count must be invariant"
368        );
369        assert!(
370            next.iter()
371                .any(|c| c.identity_did.as_str() == "did:keri:ECCCMacNew")
372        );
373        assert!(
374            !next
375                .iter()
376                .any(|c| c.identity_did.as_str() == "did:keri:EAAAMacOld")
377        );
378    }
379
380    #[test]
381    fn swap_replaces_in_place() {
382        let current = vec![
383            controller("did:keri:EAAAMacOld"),
384            controller("did:keri:EBBBPhone"),
385        ];
386        let old = did("did:keri:EAAAMacOld");
387        let new = controller("did:keri:ECCCMacNew");
388        let next = apply_shared_kel_change(
389            &current,
390            &SharedKelChange::SwapController {
391                old: &old,
392                new: &new,
393            },
394        )
395        .expect("swap");
396        assert_eq!(next.len(), 2);
397        // Swap preserves position so verifiers' index-based lookups
398        // don't churn needlessly.
399        assert_eq!(next[0].identity_did.as_str(), "did:keri:ECCCMacNew");
400        assert_eq!(next[1].identity_did.as_str(), "did:keri:EBBBPhone");
401    }
402
403    #[test]
404    fn add_then_remove_by_did_regardless_of_index() {
405        let mut current = vec![controller("did:keri:EAAAMac")];
406        let phone_a = controller("did:keri:EAAAPhone");
407        let phone_b = controller("did:keri:EBBBPhone");
408
409        // add A
410        current =
411            apply_shared_kel_change(&current, &SharedKelChange::AddController { new: &phone_a })
412                .unwrap();
413        // add B
414        current =
415            apply_shared_kel_change(&current, &SharedKelChange::AddController { new: &phone_b })
416                .unwrap();
417
418        // Remove A by DID. B should remain — verifies the API never
419        // exposes indices (removing by stale index would have dropped B).
420        let target = did("did:keri:EAAAPhone");
421        let next = apply_shared_kel_change(
422            &current,
423            &SharedKelChange::RemoveController { target: &target },
424        )
425        .unwrap();
426        assert!(
427            next.iter()
428                .any(|c| c.identity_did.as_str() == "did:keri:EBBBPhone"),
429            "B must survive removal of A"
430        );
431        assert!(
432            !next
433                .iter()
434                .any(|c| c.identity_did.as_str() == "did:keri:EAAAPhone"),
435            "A must be gone"
436        );
437    }
438}