Skip to main content

auths_id/keri/
anchor.rs

1//! IXN events for anchoring device attestations in the KEL.
2//!
3//! Interaction events (IXN) anchor data in the KEL without rotating keys.
4//! This creates a cryptographic trust chain linking attestations to the identity.
5
6use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
7use git2::Repository;
8use ring::signature::Ed25519KeyPair;
9
10use auths_core::crypto::said::compute_said;
11
12use super::event::KeriSequence;
13use super::seal::SealType;
14use super::types::{Prefix, Said};
15use super::{
16    Event, GitKel, IxnEvent, KERI_VERSION, KelError, Seal, ValidationError, parse_did_keri,
17    validate_kel,
18};
19
20/// Error type for anchoring operations.
21#[derive(Debug, thiserror::Error)]
22#[non_exhaustive]
23pub enum AnchorError {
24    #[error("KEL error: {0}")]
25    Kel(#[from] KelError),
26
27    #[error("Validation error: {0}")]
28    Validation(#[from] ValidationError),
29
30    #[error("Serialization error: {0}")]
31    Serialization(String),
32
33    #[error("Invalid DID format: {0}")]
34    InvalidDid(String),
35
36    #[error("KEL not found for prefix: {0}")]
37    NotFound(String),
38}
39
40impl auths_core::error::AuthsErrorInfo for AnchorError {
41    fn error_code(&self) -> &'static str {
42        match self {
43            Self::Kel(_) => "AUTHS-E4961",
44            Self::Validation(_) => "AUTHS-E4962",
45            Self::Serialization(_) => "AUTHS-E4963",
46            Self::InvalidDid(_) => "AUTHS-E4964",
47            Self::NotFound(_) => "AUTHS-E4965",
48        }
49    }
50
51    fn suggestion(&self) -> Option<&'static str> {
52        match self {
53            Self::Kel(_) => None,
54            Self::Validation(_) => None,
55            Self::Serialization(_) => None,
56            Self::InvalidDid(_) => Some("Use the format 'did:keri:E<prefix>'"),
57            Self::NotFound(_) => Some("Initialize the identity first with 'auths init'"),
58        }
59    }
60}
61
62/// Result of anchor verification.
63#[derive(Debug, Clone)]
64pub struct AnchorVerification {
65    /// Whether the data is anchored in the KEL
66    pub anchored: bool,
67
68    /// The SAID of the IXN event containing the anchor (if found)
69    pub anchor_said: Option<Said>,
70
71    /// The sequence number of the anchor event (if found)
72    pub anchor_sequence: Option<u64>,
73
74    /// The signing key at the time of anchoring (if found)
75    pub signing_key: Option<String>,
76}
77
78/// Anchor arbitrary data in the KEL via an interaction event.
79///
80/// This creates an IXN event containing a seal with the data's digest.
81///
82/// # Arguments
83/// * `repo` - Git repository containing the KEL
84/// * `prefix` - The KERI identifier prefix
85/// * `data` - The data to anchor (will be serialized to JSON and hashed)
86/// * `seal_type` - The type of seal
87/// * `current_keypair` - The current signing keypair for this identity
88///
89/// # Returns
90/// * The SAID of the created IXN event
91pub fn anchor_data<T: serde::Serialize>(
92    repo: &Repository,
93    prefix: &Prefix,
94    data: &T,
95    seal_type: SealType,
96    current_keypair: &Ed25519KeyPair,
97    now: chrono::DateTime<chrono::Utc>,
98) -> Result<Said, AnchorError> {
99    let kel = GitKel::new(repo, prefix.as_str());
100    if !kel.exists() {
101        return Err(AnchorError::NotFound(prefix.as_str().to_string()));
102    }
103
104    let events = kel.get_events()?;
105    let state = validate_kel(&events)?;
106
107    // Compute data digest
108    let data_json =
109        serde_json::to_vec(data).map_err(|e| AnchorError::Serialization(e.to_string()))?;
110    let data_digest = compute_said(&data_json);
111
112    // Create seal
113    let seal = Seal::new(data_digest, seal_type);
114
115    // Build IXN event
116    let new_sequence = state.sequence + 1;
117    let mut ixn = IxnEvent {
118        v: KERI_VERSION.to_string(),
119        d: Said::default(),
120        i: prefix.clone(),
121        s: KeriSequence::new(new_sequence),
122        p: state.last_event_said.clone(),
123        a: vec![seal],
124        x: String::new(), // Signature added below
125    };
126
127    // Compute SAID
128    let ixn_json = serde_json::to_vec(&Event::Ixn(ixn.clone()))
129        .map_err(|e| AnchorError::Serialization(e.to_string()))?;
130    ixn.d = compute_said(&ixn_json);
131
132    // Sign the event with the current key
133    let canonical = super::serialize_for_signing(&Event::Ixn(ixn.clone()))?;
134    let sig = current_keypair.sign(&canonical);
135    ixn.x = URL_SAFE_NO_PAD.encode(sig.as_ref());
136
137    // Append to KEL
138    kel.append(&Event::Ixn(ixn.clone()), now)?;
139
140    Ok(ixn.d)
141}
142
143/// Anchor a device attestation in the KEL.
144///
145/// This is a convenience wrapper for `anchor_data` with the "device-attestation" seal type.
146pub fn anchor_attestation<T: serde::Serialize>(
147    repo: &Repository,
148    prefix: &Prefix,
149    attestation: &T,
150    current_keypair: &Ed25519KeyPair,
151    now: chrono::DateTime<chrono::Utc>,
152) -> Result<Said, AnchorError> {
153    anchor_data(
154        repo,
155        prefix,
156        attestation,
157        SealType::DeviceAttestation,
158        current_keypair,
159        now,
160    )
161}
162
163/// Anchor an IdP binding in the KEL.
164///
165/// This is a convenience wrapper for `anchor_data` with the "idp-binding" seal type.
166pub fn anchor_idp_binding<T: serde::Serialize>(
167    repo: &Repository,
168    prefix: &Prefix,
169    binding: &T,
170    current_keypair: &Ed25519KeyPair,
171    now: chrono::DateTime<chrono::Utc>,
172) -> Result<Said, AnchorError> {
173    anchor_data(
174        repo,
175        prefix,
176        binding,
177        SealType::IdpBinding,
178        current_keypair,
179        now,
180    )
181}
182
183/// Find the IXN event that anchors a specific data digest.
184///
185/// # Arguments
186/// * `repo` - Git repository containing the KEL
187/// * `prefix` - The KERI identifier prefix
188/// * `data_digest` - The SAID of the anchored data
189///
190/// # Returns
191/// * The IXN event if found, None otherwise
192pub fn find_anchor_event(
193    repo: &Repository,
194    prefix: &Prefix,
195    data_digest: &str,
196) -> Result<Option<IxnEvent>, AnchorError> {
197    let kel = GitKel::new(repo, prefix.as_str());
198    if !kel.exists() {
199        return Err(AnchorError::NotFound(prefix.as_str().to_string()));
200    }
201
202    let events = kel.get_events()?;
203
204    for event in events {
205        if let Event::Ixn(ixn) = event {
206            for seal in &ixn.a {
207                if seal.d == data_digest {
208                    return Ok(Some(ixn));
209                }
210            }
211        }
212    }
213
214    Ok(None)
215}
216
217/// Verify that data is properly anchored in a KEL.
218///
219/// This finds the anchor event and validates the KEL up to that point.
220///
221/// # Arguments
222/// * `repo` - Git repository containing the KEL
223/// * `prefix` - The KERI identifier prefix
224/// * `data` - The data to verify anchoring for
225///
226/// # Returns
227/// * `AnchorVerification` with details about the anchor
228pub fn verify_anchor<T: serde::Serialize>(
229    repo: &Repository,
230    prefix: &Prefix,
231    data: &T,
232) -> Result<AnchorVerification, AnchorError> {
233    // Compute data digest
234    let data_json =
235        serde_json::to_vec(data).map_err(|e| AnchorError::Serialization(e.to_string()))?;
236    let data_digest = compute_said(&data_json);
237
238    verify_anchor_by_digest(repo, prefix, data_digest.as_str())
239}
240
241/// Verify anchor by digest (when you already have the digest).
242pub fn verify_anchor_by_digest(
243    repo: &Repository,
244    prefix: &Prefix,
245    data_digest: &str,
246) -> Result<AnchorVerification, AnchorError> {
247    let kel = GitKel::new(repo, prefix.as_str());
248    if !kel.exists() {
249        return Err(AnchorError::NotFound(prefix.as_str().to_string()));
250    }
251
252    // Find anchor event
253    let anchor = find_anchor_event(repo, prefix, data_digest)?;
254
255    match anchor {
256        Some(ixn) => {
257            let events = kel.get_events()?;
258
259            let anchor_seq = ixn.s.value();
260            let events_subset: Vec<_> = events
261                .into_iter()
262                .take_while(|e| e.sequence().value() <= anchor_seq)
263                .collect();
264
265            let state = validate_kel(&events_subset)?;
266
267            Ok(AnchorVerification {
268                anchored: true,
269                anchor_said: Some(ixn.d),
270                anchor_sequence: Some(anchor_seq),
271                signing_key: state.current_key().map(|s| s.to_string()),
272            })
273        }
274        None => Ok(AnchorVerification {
275            anchored: false,
276            anchor_said: None,
277            anchor_sequence: None,
278            signing_key: None,
279        }),
280    }
281}
282
283/// Verify that an attestation is properly anchored, extracting the issuer DID.
284///
285/// This is a convenience function that extracts the issuer from the attestation
286/// and verifies the anchor.
287pub fn verify_attestation_anchor_by_issuer<T: serde::Serialize>(
288    repo: &Repository,
289    issuer_did: &str,
290    attestation: &T,
291) -> Result<AnchorVerification, AnchorError> {
292    let prefix: Prefix =
293        parse_did_keri(issuer_did).map_err(|e| AnchorError::InvalidDid(e.to_string()))?;
294    verify_anchor(repo, &prefix, attestation)
295}
296
297#[cfg(test)]
298#[allow(clippy::disallowed_methods)]
299mod tests {
300    use super::*;
301    use crate::keri::{Prefix, create_keri_identity};
302    use ring::signature::Ed25519KeyPair as TestKeyPair;
303    use serde::{Deserialize, Serialize};
304    use tempfile::TempDir;
305
306    fn setup_repo() -> (TempDir, Repository) {
307        let dir = TempDir::new().unwrap();
308        let repo = Repository::init(dir.path()).unwrap();
309
310        let mut config = repo.config().unwrap();
311        config.set_str("user.name", "Test User").unwrap();
312        config.set_str("user.email", "test@example.com").unwrap();
313
314        (dir, repo)
315    }
316
317    #[derive(Debug, Serialize, Deserialize)]
318    struct TestAttestation {
319        issuer: String,
320        subject: String,
321        capabilities: Vec<String>,
322    }
323
324    fn make_test_attestation(issuer: &str, subject: &str) -> TestAttestation {
325        TestAttestation {
326            issuer: issuer.to_string(),
327            subject: subject.to_string(),
328            capabilities: vec!["sign-commit".to_string()],
329        }
330    }
331
332    #[test]
333    fn anchor_creates_ixn_event() {
334        let (_dir, repo) = setup_repo();
335
336        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
337        let issuer_did = format!("did:keri:{}", init.prefix);
338        let current_keypair = TestKeyPair::from_pkcs8(init.current_keypair_pkcs8.as_ref()).unwrap();
339
340        let attestation = make_test_attestation(&issuer_did, "did:key:device123");
341        let anchor_said = anchor_attestation(
342            &repo,
343            &init.prefix,
344            &attestation,
345            &current_keypair,
346            chrono::Utc::now(),
347        )
348        .unwrap();
349
350        // Verify IXN was created
351        let kel = GitKel::new(&repo, init.prefix.as_str());
352        let events = kel.get_events().unwrap();
353        assert_eq!(events.len(), 2); // ICP + IXN
354
355        assert!(events[0].is_inception());
356        assert!(events[1].is_interaction());
357
358        if let Event::Ixn(ixn) = &events[1] {
359            assert_eq!(ixn.d, anchor_said);
360            assert_eq!(ixn.a.len(), 1);
361            assert_eq!(ixn.a[0].seal_type, SealType::DeviceAttestation);
362        } else {
363            panic!("Expected IXN event");
364        }
365    }
366
367    #[test]
368    fn anchor_with_delegation_seal_type() {
369        let (_dir, repo) = setup_repo();
370
371        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
372        let current_keypair = TestKeyPair::from_pkcs8(init.current_keypair_pkcs8.as_ref()).unwrap();
373
374        let data = serde_json::json!({"delegation": "data"});
375        let anchor_said = anchor_data(
376            &repo,
377            &init.prefix,
378            &data,
379            SealType::Delegation,
380            &current_keypair,
381            chrono::Utc::now(),
382        )
383        .unwrap();
384
385        let kel = GitKel::new(&repo, init.prefix.as_str());
386        let events = kel.get_events().unwrap();
387
388        if let Event::Ixn(ixn) = &events[1] {
389            assert_eq!(ixn.d, anchor_said);
390            assert_eq!(ixn.a[0].seal_type, SealType::Delegation);
391        } else {
392            panic!("Expected IXN event");
393        }
394    }
395
396    #[test]
397    fn find_anchor_locates_attestation() {
398        let (_dir, repo) = setup_repo();
399
400        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
401        let issuer_did = format!("did:keri:{}", init.prefix);
402        let current_keypair = TestKeyPair::from_pkcs8(init.current_keypair_pkcs8.as_ref()).unwrap();
403
404        let attestation = make_test_attestation(&issuer_did, "did:key:device123");
405        anchor_attestation(
406            &repo,
407            &init.prefix,
408            &attestation,
409            &current_keypair,
410            chrono::Utc::now(),
411        )
412        .unwrap();
413
414        // Compute the digest we're looking for
415        let att_json = serde_json::to_vec(&attestation).unwrap();
416        let att_digest = compute_said(&att_json);
417
418        let found = find_anchor_event(&repo, &init.prefix, att_digest.as_str()).unwrap();
419        assert!(found.is_some());
420        assert_eq!(found.unwrap().a[0].d, att_digest);
421    }
422
423    #[test]
424    fn verify_anchor_works() {
425        let (_dir, repo) = setup_repo();
426
427        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
428        let issuer_did = format!("did:keri:{}", init.prefix);
429        let current_keypair = TestKeyPair::from_pkcs8(init.current_keypair_pkcs8.as_ref()).unwrap();
430
431        let attestation = make_test_attestation(&issuer_did, "did:key:device123");
432        anchor_attestation(
433            &repo,
434            &init.prefix,
435            &attestation,
436            &current_keypair,
437            chrono::Utc::now(),
438        )
439        .unwrap();
440
441        let verification = verify_anchor(&repo, &init.prefix, &attestation).unwrap();
442        assert!(verification.anchored);
443        assert!(verification.anchor_said.is_some());
444        assert_eq!(verification.anchor_sequence, Some(1));
445        assert!(verification.signing_key.is_some());
446    }
447
448    #[test]
449    fn unanchored_attestation_not_found() {
450        let (_dir, repo) = setup_repo();
451
452        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
453        let issuer_did = format!("did:keri:{}", init.prefix);
454
455        let attestation = make_test_attestation(&issuer_did, "did:key:device123");
456        // Don't anchor it
457
458        let verification = verify_anchor(&repo, &init.prefix, &attestation).unwrap();
459        assert!(!verification.anchored);
460        assert!(verification.anchor_said.is_none());
461    }
462
463    #[test]
464    fn multiple_anchors_work() {
465        let (_dir, repo) = setup_repo();
466
467        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
468        let issuer_did = format!("did:keri:{}", init.prefix);
469        let current_keypair = TestKeyPair::from_pkcs8(init.current_keypair_pkcs8.as_ref()).unwrap();
470
471        let att1 = make_test_attestation(&issuer_did, "did:key:device1");
472        let att2 = make_test_attestation(&issuer_did, "did:key:device2");
473
474        let said1 = anchor_attestation(
475            &repo,
476            &init.prefix,
477            &att1,
478            &current_keypair,
479            chrono::Utc::now(),
480        )
481        .unwrap();
482        let said2 = anchor_attestation(
483            &repo,
484            &init.prefix,
485            &att2,
486            &current_keypair,
487            chrono::Utc::now(),
488        )
489        .unwrap();
490
491        assert_ne!(said1, said2);
492
493        // Verify both are anchored
494        let v1 = verify_anchor(&repo, &init.prefix, &att1).unwrap();
495        let v2 = verify_anchor(&repo, &init.prefix, &att2).unwrap();
496
497        assert!(v1.anchored);
498        assert!(v2.anchored);
499        assert_eq!(v1.anchor_sequence, Some(1));
500        assert_eq!(v2.anchor_sequence, Some(2));
501    }
502
503    #[test]
504    fn verify_by_issuer_did() {
505        let (_dir, repo) = setup_repo();
506
507        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
508        let issuer_did = format!("did:keri:{}", init.prefix);
509        let current_keypair = TestKeyPair::from_pkcs8(init.current_keypair_pkcs8.as_ref()).unwrap();
510
511        let attestation = make_test_attestation(&issuer_did, "did:key:device123");
512        anchor_attestation(
513            &repo,
514            &init.prefix,
515            &attestation,
516            &current_keypair,
517            chrono::Utc::now(),
518        )
519        .unwrap();
520
521        let verification =
522            verify_attestation_anchor_by_issuer(&repo, &issuer_did, &attestation).unwrap();
523        assert!(verification.anchored);
524    }
525
526    #[test]
527    fn anchor_not_found_for_missing_kel() {
528        let (_dir, repo) = setup_repo();
529
530        // Create an identity to get a valid keypair (for the signature requirement)
531        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
532        let current_keypair = TestKeyPair::from_pkcs8(init.current_keypair_pkcs8.as_ref()).unwrap();
533
534        // Try to anchor to a non-existent KEL
535        let attestation = make_test_attestation("did:keri:ENotExist", "did:key:device");
536        let fake_prefix = Prefix::new_unchecked("ENotExist".to_string());
537        let result = anchor_attestation(
538            &repo,
539            &fake_prefix,
540            &attestation,
541            &current_keypair,
542            chrono::Utc::now(),
543        );
544        assert!(matches!(result, Err(AnchorError::NotFound(_))));
545    }
546}