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(¤t, &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(¤t, &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(¤t, &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 ¤t,
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 ¤t,
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 ¤t,
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(¤t, &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 ¤t,
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(¤t, &SharedKelChange::AddController { new: &phone_a })
413 .unwrap();
414 // add B
415 current =
416 apply_shared_kel_change(¤t, &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 ¤t,
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}