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).map_err(|e| {
266                SharedKelError::EventConstruction(format!("Ed25519 verkey invalid: {e}"))
267            })?
268        }
269        CurveType::P256 => {
270            let arr: [u8; 33] = verkey_bytes.as_slice().try_into().map_err(|_| {
271                SharedKelError::EventConstruction(
272                    "P-256 verkey must be 33-byte compressed SEC1".into(),
273                )
274            })?;
275            KeriPublicKey::P256 {
276                key: arr,
277                transferable: true,
278            }
279        }
280    };
281    Ok(ControllerDescriptor {
282        identity_did: did,
283        current_verkey,
284    })
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn did(s: &str) -> IdentityDID {
292        IdentityDID::parse(s).unwrap()
293    }
294
295    fn controller(did_str: &str) -> ControllerDescriptor {
296        ControllerDescriptor {
297            identity_did: did(did_str),
298            current_verkey: KeriPublicKey::ed25519(&[0u8; 32]).unwrap(),
299        }
300    }
301
302    #[test]
303    fn add_appends_controller() {
304        let current = vec![controller("did:keri:EAAAMac")];
305        let new = controller("did:keri:EBBBPhone");
306        let next = apply_shared_kel_change(&current, &SharedKelChange::AddController { new: &new })
307            .expect("add");
308        assert_eq!(next.len(), 2);
309        assert_eq!(next[1].identity_did.as_str(), "did:keri:EBBBPhone");
310    }
311
312    #[test]
313    fn remove_drops_named_controller() {
314        let current = vec![
315            controller("did:keri:EAAAMac"),
316            controller("did:keri:EBBBPhone"),
317        ];
318        let target = did("did:keri:EAAAMac");
319        let next = apply_shared_kel_change(
320            &current,
321            &SharedKelChange::RemoveController { target: &target },
322        )
323        .expect("remove");
324        assert_eq!(next.len(), 1);
325        assert_eq!(next[0].identity_did.as_str(), "did:keri:EBBBPhone");
326    }
327
328    #[test]
329    fn remove_of_missing_controller_errors() {
330        let current = vec![controller("did:keri:EAAAMac")];
331        let target = did("did:keri:EZZZMissing");
332        let err = apply_shared_kel_change(
333            &current,
334            &SharedKelChange::RemoveController { target: &target },
335        )
336        .unwrap_err();
337        assert!(matches!(err, SharedKelError::ControllerNotFound(_)));
338    }
339
340    #[test]
341    fn remove_of_last_controller_would_orphan_errors() {
342        let current = vec![controller("did:keri:EAAAMac")];
343        let target = did("did:keri:EAAAMac");
344        let err = apply_shared_kel_change(
345            &current,
346            &SharedKelChange::RemoveController { target: &target },
347        )
348        .unwrap_err();
349        assert!(matches!(err, SharedKelError::WouldOrphanIdentity));
350    }
351
352    #[test]
353    fn swap_is_atomic_controller_count_invariant() {
354        // Atomicity check: after swap, controller count must equal the
355        // prior count. A verifier must never observe an intermediate
356        // state with fewer controllers (what a naive remove-then-add
357        // would produce if it weren't composed into a single rot).
358        let current = vec![
359            controller("did:keri:EAAAMacOld"),
360            controller("did:keri:EBBBPhone"),
361        ];
362        let old = did("did:keri:EAAAMacOld");
363        let new = controller("did:keri:ECCCMacNew");
364        let next = rot_swap_controller(&current, &old, &new).expect("swap");
365        assert_eq!(
366            next.len(),
367            current.len(),
368            "controller count must be invariant"
369        );
370        assert!(
371            next.iter()
372                .any(|c| c.identity_did.as_str() == "did:keri:ECCCMacNew")
373        );
374        assert!(
375            !next
376                .iter()
377                .any(|c| c.identity_did.as_str() == "did:keri:EAAAMacOld")
378        );
379    }
380
381    #[test]
382    fn swap_replaces_in_place() {
383        let current = vec![
384            controller("did:keri:EAAAMacOld"),
385            controller("did:keri:EBBBPhone"),
386        ];
387        let old = did("did:keri:EAAAMacOld");
388        let new = controller("did:keri:ECCCMacNew");
389        let next = apply_shared_kel_change(
390            &current,
391            &SharedKelChange::SwapController {
392                old: &old,
393                new: &new,
394            },
395        )
396        .expect("swap");
397        assert_eq!(next.len(), 2);
398        // Swap preserves position so verifiers' index-based lookups
399        // don't churn needlessly.
400        assert_eq!(next[0].identity_did.as_str(), "did:keri:ECCCMacNew");
401        assert_eq!(next[1].identity_did.as_str(), "did:keri:EBBBPhone");
402    }
403
404    #[test]
405    fn add_then_remove_by_did_regardless_of_index() {
406        let mut current = vec![controller("did:keri:EAAAMac")];
407        let phone_a = controller("did:keri:EAAAPhone");
408        let phone_b = controller("did:keri:EBBBPhone");
409
410        // add A
411        current =
412            apply_shared_kel_change(&current, &SharedKelChange::AddController { new: &phone_a })
413                .unwrap();
414        // add B
415        current =
416            apply_shared_kel_change(&current, &SharedKelChange::AddController { new: &phone_b })
417                .unwrap();
418
419        // Remove A by DID. B should remain — verifies the API never
420        // exposes indices (removing by stale index would have dropped B).
421        let target = did("did:keri:EAAAPhone");
422        let next = apply_shared_kel_change(
423            &current,
424            &SharedKelChange::RemoveController { target: &target },
425        )
426        .unwrap();
427        assert!(
428            next.iter()
429                .any(|c| c.identity_did.as_str() == "did:keri:EBBBPhone"),
430            "B must survive removal of A"
431        );
432        assert!(
433            !next
434                .iter()
435                .any(|c| c.identity_did.as_str() == "did:keri:EAAAPhone"),
436            "A must be gone"
437        );
438    }
439}