auths-id 0.1.2

Multi-device identity and attestation crate for Auths
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
//! Shared identity KEL — one KEL whose controllers are the user's devices.
//!
//! The user's identity is a KEL whose `k` list enumerates the currently
//! trusted device KEL DIDs. Pairing a new device = `rot` that appends a
//! controller. Retiring a device = `rot` that drops one. Stolen-laptop
//! recovery = swap (drop + add in the same rotation).
//!
//! **Controller identity model (load-bearing)**: controllers are device
//! `did:keri:` prefixes, not raw verkeys. When the shared KEL's `rot`
//! events record `k`, the bytes are the device's *current verkey at the
//! time the rot was authored* — but the semantic identity of a
//! controller across rotations is its `did:keri:`. Verifiers resolve a
//! controller signature by (1) walking the device's own KEL to the
//! current key and (2) checking the signature against that key.
//!
//! A device can rotate its own KEL without touching the shared KEL —
//! verifiers re-resolve lazily. The shared KEL only changes when the
//! *set* of controllers changes.
//!
//! Threshold is `kt=1` for now — any single controller can sign a
//! shared-KEL rotation. Raising the threshold is a separate, later
//! change.
//!
//! **Superseded (2026-06-03):** multi-device membership moved to KERI
//! delegation (`keri::delegation`) — a device is a *delegated identifier* the
//! root anchors via `ixn`, not a controller in a shared `k` list. One device
//! cannot author a keripy-valid `rot` of a multi-device `k` (it can't reveal the
//! other devices' pre-committed keys). This module is retained for reference;
//! see `docs/architecture/device-model.md` "Design decision (2026-06-03)".

use auths_core::storage::keychain::IdentityDID;
use auths_crypto::CurveType;
use auths_keri::KeriPublicKey;

use super::{Prefix, Said};

/// One controller of a shared identity KEL.
///
/// Binds the semantic identity (the device's `did:keri:` prefix) to the
/// verkey currently committed under that identity. The verkey is what
/// the shared KEL's `k` list stores at the moment the event is signed;
/// subsequent device-side rotations do not require a shared-KEL update
/// because verifiers re-resolve via the device's own KEL.
#[derive(Debug, Clone)]
pub struct ControllerDescriptor {
    pub identity_did: IdentityDID,
    pub current_verkey: KeriPublicKey,
}

/// Opaque handle describing a freshly-inceptioned shared identity KEL.
#[derive(Debug, Clone)]
pub struct SharedKelArtifacts {
    pub prefix: Prefix,
    pub inception_event_json: String,
    pub controllers: Vec<ControllerDescriptor>,
    /// Always 1 for now — any single controller can sign.
    // TODO(stage-3): raise the threshold so compromising one device
    // cannot rewrite the controller set.
    pub kt: u32,
}

/// Opaque handle describing a `rot` event on the shared KEL.
#[derive(Debug, Clone)]
pub struct SharedKelRotArtifacts {
    pub prefix: Prefix,
    /// The SAID (`d` field) of the produced rotation event.
    pub event_said: Said,
    /// Serialized `rot` JSON, suitable for replication.
    pub event_json: String,
    /// New controller set after this rotation.
    pub controllers: Vec<ControllerDescriptor>,
}

// ---------------------------------------------------------------------------
// Add-only event authorship (symmetric growth, validator-safe today)
// ---------------------------------------------------------------------------
//
// These helpers thin-wrap the existing multi-slot machinery in
// `identity::initialize` + `identity::rotate` so the shared-KEL semantic
// layer has a single public surface. (Superseded by `keri::delegation`.)

/// Inception entry point for a shared identity KEL.
///
/// Thin wrapper over `initialize_registry_identity_multi`. The caller
/// supplies a set of controller descriptors (one per paired device's
/// current verkey + DID); the function inceptions the shared KEL under
/// `refs/auths/shared-kel/<prefix>/*` with `kt=1` — any single
/// controller may sign subsequent rotations.
///
/// This is a marker helper — it reuses the existing multi-slot
/// inception code path. The caller is still responsible for providing
/// a `RegistryBackend` + `KeyStorage` that knows how to persist under
/// the shared-KEL namespace.
pub fn incept_shared_kel_prepared(
    controllers: &[ControllerDescriptor],
) -> Result<Vec<ControllerDescriptor>, SharedKelError> {
    if controllers.is_empty() {
        return Err(SharedKelError::WouldOrphanIdentity);
    }
    Ok(controllers.to_vec())
}

/// Add-controller rotation entry point.
///
/// Builds the new controller set from the prior one plus `new` and
/// returns it ready for persistence. Structurally a symmetric-growth
/// rotation — the validator accepts it without indexed-signature
/// support.
pub fn rot_add_controller(
    current: &[ControllerDescriptor],
    new: &ControllerDescriptor,
) -> Result<Vec<ControllerDescriptor>, SharedKelError> {
    apply_shared_kel_change(current, &SharedKelChange::AddController { new })
}

/// Remove-controller rotation entry point.
///
/// Returns the new controller set with `target` dropped. The shrink `rot` is
/// authored by [`crate::identity::rotate::rotate_registry_identity_multi`] with
/// the target's slot in `remove_indices`; a surviving controller signs a
/// dual-index rotation the validator now accepts. The orphan guard
/// ([`SharedKelError::WouldOrphanIdentity`]) is enforced in
/// [`apply_shared_kel_change`].
pub fn rot_remove_controller(
    current: &[ControllerDescriptor],
    target: &IdentityDID,
) -> Result<Vec<ControllerDescriptor>, SharedKelError> {
    apply_shared_kel_change(current, &SharedKelChange::RemoveController { target })
}

/// Change requested by a shared-KEL rotation caller.
///
/// Callers pass DIDs; the rotation implementation resolves them to the
/// current controller-list indices internally. Indices shift across
/// rotations — exposing them in the public API would be a footgun.
#[derive(Debug, Clone)]
pub enum SharedKelChange<'a> {
    /// Add a new controller (device) to the identity.
    AddController { new: &'a ControllerDescriptor },
    /// Remove the controller identified by DID. Errors if the DID is
    /// not in the current controller set, or if removing it would
    /// leave the identity with no controllers.
    RemoveController { target: &'a IdentityDID },
    /// Stolen-laptop recovery: drop `old` and add `new` in a single
    /// rotation. Atomic — a verifier never sees an intermediate state
    /// where the identity has one controller less.
    SwapController {
        old: &'a IdentityDID,
        new: &'a ControllerDescriptor,
    },
}

/// Errors specific to shared-KEL operations.
#[derive(Debug, thiserror::Error)]
pub enum SharedKelError {
    /// The DID passed to `RemoveController` / `SwapController` is not a
    /// current controller.
    #[error("controller {0} is not in the current shared-KEL controller set")]
    ControllerNotFound(String),
    /// The requested rotation would leave the shared KEL with no
    /// controllers — the identity would be orphaned.
    #[error("rotation would orphan the identity (no remaining controllers)")]
    WouldOrphanIdentity,
    /// Construction of the rotation event failed.
    #[error("rotation event construction failed: {0}")]
    EventConstruction(String),
}

/// Resolve a controller DID to its index in the current controller
/// list, returning [`SharedKelError::ControllerNotFound`] if absent.
pub fn resolve_controller_index(
    controllers: &[ControllerDescriptor],
    target: &IdentityDID,
) -> Result<usize, SharedKelError> {
    controllers
        .iter()
        .position(|c| c.identity_did.as_str() == target.as_str())
        .ok_or_else(|| SharedKelError::ControllerNotFound(target.as_str().to_string()))
}

/// Apply a [`SharedKelChange`] to the controller list, returning the
/// new controller list or an error if the change is invalid.
///
/// Args:
/// * `current`: Current controller set (from the prior KEL state).
/// * `change`: The requested change.
///
/// Usage:
/// ```ignore
/// let next = apply_shared_kel_change(&current, &change)?;
/// ```
pub fn apply_shared_kel_change(
    current: &[ControllerDescriptor],
    change: &SharedKelChange<'_>,
) -> Result<Vec<ControllerDescriptor>, SharedKelError> {
    match change {
        SharedKelChange::AddController { new } => {
            let mut next: Vec<ControllerDescriptor> = current.to_vec();
            next.push((*new).clone());
            Ok(next)
        }
        SharedKelChange::RemoveController { target } => {
            let idx = resolve_controller_index(current, target)?;
            if current.len() <= 1 {
                return Err(SharedKelError::WouldOrphanIdentity);
            }
            let mut next: Vec<ControllerDescriptor> = current.to_vec();
            next.remove(idx);
            Ok(next)
        }
        SharedKelChange::SwapController { old, new } => {
            let idx = resolve_controller_index(current, old)?;
            let mut next: Vec<ControllerDescriptor> = current.to_vec();
            next[idx] = (*new).clone();
            Ok(next)
        }
    }
}

/// Atomically replace one controller with another.
///
/// Convenience wrapper over [`apply_shared_kel_change`] that composes
/// a single [`SharedKelChange::SwapController`] — a verifier never
/// observes an intermediate state where the identity has fewer
/// controllers than the prior rotation. Used by the stolen-laptop
/// recovery flow, where the surviving controller signs one `rot`
/// that drops the lost device's DID and adds the new device's DID.
///
/// The caller is still responsible for emitting the `rot` event to
/// disk; this helper just computes the target controller set.
///
/// Args:
/// * `current`: Current controller set (from prior KEL state).
/// * `old_did`: DID to drop — must be in the current controller set.
/// * `new`: Descriptor for the replacement device.
///
/// Usage:
/// ```ignore
/// let next = rot_swap_controller(&current, &old_mac_did, &new_mac_ctrl)?;
/// ```
pub fn rot_swap_controller(
    current: &[ControllerDescriptor],
    old_did: &IdentityDID,
    new: &ControllerDescriptor,
) -> Result<Vec<ControllerDescriptor>, SharedKelError> {
    apply_shared_kel_change(
        current,
        &SharedKelChange::SwapController { old: old_did, new },
    )
}

/// Controllers a device KEL may represent — helper for callers that
/// need to bind their own device as a ControllerDescriptor without
/// exposing the verkey encoding details of `auths-keri`.
pub fn controller_from_parts(
    did: IdentityDID,
    verkey_bytes: Vec<u8>,
    curve: CurveType,
) -> Result<ControllerDescriptor, SharedKelError> {
    let current_verkey = match curve {
        CurveType::Ed25519 => {
            let arr: [u8; 32] = verkey_bytes.as_slice().try_into().map_err(|_| {
                SharedKelError::EventConstruction("Ed25519 verkey must be 32 bytes".into())
            })?;
            KeriPublicKey::Ed25519(arr)
        }
        CurveType::P256 => {
            let arr: [u8; 33] = verkey_bytes.as_slice().try_into().map_err(|_| {
                SharedKelError::EventConstruction(
                    "P-256 verkey must be 33-byte compressed SEC1".into(),
                )
            })?;
            KeriPublicKey::P256 {
                key: arr,
                transferable: true,
            }
        }
    };
    Ok(ControllerDescriptor {
        identity_did: did,
        current_verkey,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn did(s: &str) -> IdentityDID {
        #[allow(clippy::disallowed_methods)]
        IdentityDID::new_unchecked(s.to_string())
    }

    fn controller(did_str: &str) -> ControllerDescriptor {
        ControllerDescriptor {
            identity_did: did(did_str),
            current_verkey: KeriPublicKey::Ed25519([0u8; 32]),
        }
    }

    #[test]
    fn add_appends_controller() {
        let current = vec![controller("did:keri:EAAAMac")];
        let new = controller("did:keri:EBBBPhone");
        let next = apply_shared_kel_change(&current, &SharedKelChange::AddController { new: &new })
            .expect("add");
        assert_eq!(next.len(), 2);
        assert_eq!(next[1].identity_did.as_str(), "did:keri:EBBBPhone");
    }

    #[test]
    fn remove_drops_named_controller() {
        let current = vec![
            controller("did:keri:EAAAMac"),
            controller("did:keri:EBBBPhone"),
        ];
        let target = did("did:keri:EAAAMac");
        let next = apply_shared_kel_change(
            &current,
            &SharedKelChange::RemoveController { target: &target },
        )
        .expect("remove");
        assert_eq!(next.len(), 1);
        assert_eq!(next[0].identity_did.as_str(), "did:keri:EBBBPhone");
    }

    #[test]
    fn remove_of_missing_controller_errors() {
        let current = vec![controller("did:keri:EAAAMac")];
        let target = did("did:keri:EZZZMissing");
        let err = apply_shared_kel_change(
            &current,
            &SharedKelChange::RemoveController { target: &target },
        )
        .unwrap_err();
        assert!(matches!(err, SharedKelError::ControllerNotFound(_)));
    }

    #[test]
    fn remove_of_last_controller_would_orphan_errors() {
        let current = vec![controller("did:keri:EAAAMac")];
        let target = did("did:keri:EAAAMac");
        let err = apply_shared_kel_change(
            &current,
            &SharedKelChange::RemoveController { target: &target },
        )
        .unwrap_err();
        assert!(matches!(err, SharedKelError::WouldOrphanIdentity));
    }

    #[test]
    fn swap_is_atomic_controller_count_invariant() {
        // Atomicity check: after swap, controller count must equal the
        // prior count. A verifier must never observe an intermediate
        // state with fewer controllers (what a naive remove-then-add
        // would produce if it weren't composed into a single rot).
        let current = vec![
            controller("did:keri:EAAAMacOld"),
            controller("did:keri:EBBBPhone"),
        ];
        let old = did("did:keri:EAAAMacOld");
        let new = controller("did:keri:ECCCMacNew");
        let next = rot_swap_controller(&current, &old, &new).expect("swap");
        assert_eq!(
            next.len(),
            current.len(),
            "controller count must be invariant"
        );
        assert!(
            next.iter()
                .any(|c| c.identity_did.as_str() == "did:keri:ECCCMacNew")
        );
        assert!(
            !next
                .iter()
                .any(|c| c.identity_did.as_str() == "did:keri:EAAAMacOld")
        );
    }

    #[test]
    fn swap_replaces_in_place() {
        let current = vec![
            controller("did:keri:EAAAMacOld"),
            controller("did:keri:EBBBPhone"),
        ];
        let old = did("did:keri:EAAAMacOld");
        let new = controller("did:keri:ECCCMacNew");
        let next = apply_shared_kel_change(
            &current,
            &SharedKelChange::SwapController {
                old: &old,
                new: &new,
            },
        )
        .expect("swap");
        assert_eq!(next.len(), 2);
        // Swap preserves position so verifiers' index-based lookups
        // don't churn needlessly.
        assert_eq!(next[0].identity_did.as_str(), "did:keri:ECCCMacNew");
        assert_eq!(next[1].identity_did.as_str(), "did:keri:EBBBPhone");
    }

    #[test]
    fn add_then_remove_by_did_regardless_of_index() {
        let mut current = vec![controller("did:keri:EAAAMac")];
        let phone_a = controller("did:keri:EAAAPhone");
        let phone_b = controller("did:keri:EBBBPhone");

        // add A
        current =
            apply_shared_kel_change(&current, &SharedKelChange::AddController { new: &phone_a })
                .unwrap();
        // add B
        current =
            apply_shared_kel_change(&current, &SharedKelChange::AddController { new: &phone_b })
                .unwrap();

        // Remove A by DID. B should remain — verifies the API never
        // exposes indices (removing by stale index would have dropped B).
        let target = did("did:keri:EAAAPhone");
        let next = apply_shared_kel_change(
            &current,
            &SharedKelChange::RemoveController { target: &target },
        )
        .unwrap();
        assert!(
            next.iter()
                .any(|c| c.identity_did.as_str() == "did:keri:EBBBPhone"),
            "B must survive removal of A"
        );
        assert!(
            !next
                .iter()
                .any(|c| c.identity_did.as_str() == "did:keri:EAAAPhone"),
            "A must be gone"
        );
    }
}