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