Skip to main content

auths_id/keri/
anchor.rs

1//! IXN-based anchoring for trust-affecting attestations in the KEL.
2//!
3//! All trust-affecting flows (device-link, device-revoke, org-membership,
4//! artifact signing) use [`anchor_and_persist`] or [`anchor_and_persist_via_backend`].
5//! There is one anchoring path — no per-operation wrappers.
6
7use git2::Repository;
8
9use auths_core::signing::{PassphraseProvider, SecureSigner};
10use auths_core::storage::keychain::KeyAlias;
11use auths_keri::compute_said;
12
13use super::event::{KeriSequence, VersionString};
14use super::types::{Prefix, Said};
15use super::{
16    Event, GitKel, IxnEvent, KelError, Seal, ValidationError, parse_did_keri,
17    serialize_for_signing, 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    #[error("Signing error: {0}")]
40    Signing(String),
41
42    #[error("Identity cannot emit interaction events: {0}")]
43    IxnForbidden(String),
44
45    #[error("Witness quorum not met: {0}")]
46    WitnessQuorumNotMet(String),
47}
48
49impl auths_core::error::AuthsErrorInfo for AnchorError {
50    fn error_code(&self) -> &'static str {
51        match self {
52            Self::Kel(_) => "AUTHS-E4961",
53            Self::Validation(_) => "AUTHS-E4962",
54            Self::Serialization(_) => "AUTHS-E4963",
55            Self::InvalidDid(_) => "AUTHS-E4964",
56            Self::NotFound(_) => "AUTHS-E4965",
57            Self::Signing(_) => "AUTHS-E4966",
58            Self::IxnForbidden(_) => "AUTHS-E4967",
59            Self::WitnessQuorumNotMet(_) => "AUTHS-E4968",
60        }
61    }
62
63    fn suggestion(&self) -> Option<&'static str> {
64        match self {
65            Self::InvalidDid(_) => Some("Use the format 'did:keri:E<prefix>'"),
66            Self::NotFound(_) => Some("Initialize the identity first with 'auths init'"),
67            Self::Signing(_) => {
68                Some("Check that the key alias exists and the passphrase is correct")
69            }
70            Self::IxnForbidden(_) => Some(
71                "Device authorization requires a transferable identity (non-empty n[]) without \
72                 establishment-only restriction (no EO in c[]). Create a new identity with auths init.",
73            ),
74            Self::WitnessQuorumNotMet(_) => {
75                Some("Check witness server connectivity and threshold configuration")
76            }
77            _ => None,
78        }
79    }
80}
81
82pub use auths_keri::AnchorStatus;
83
84/// Result of anchor verification.
85#[derive(Debug, Clone)]
86pub struct AnchorVerification {
87    /// Whether and how the data is anchored in the KEL.
88    pub status: AnchorStatus,
89
90    /// The SAID of the IXN event containing the anchor (if found)
91    pub anchor_said: Option<Said>,
92
93    /// The sequence number of the anchor event (if found)
94    pub anchor_sequence: Option<u128>,
95
96    /// The signing key at the time of anchoring (if found)
97    pub signing_key: Option<String>,
98}
99
100fn ixn_forbidden_reason(state: &auths_keri::KeyState) -> String {
101    if state.is_non_transferable {
102        "non-transferable identity (empty n[] at inception)".to_string()
103    } else {
104        "establishment-only identity (EO in c[])".to_string()
105    }
106}
107
108fn build_anchor_ixn(
109    attestation_said: &Said,
110    controller_prefix: &Prefix,
111    state: &auths_keri::KeyState,
112) -> Result<IxnEvent, AnchorError> {
113    let seal = Seal::digest(attestation_said.as_str());
114    let ixn = IxnEvent {
115        v: VersionString::placeholder(),
116        d: Said::default(),
117        i: controller_prefix.clone(),
118        s: KeriSequence::new(state.sequence + 1),
119        p: state.last_event_said.clone(),
120        a: vec![seal],
121    };
122    auths_keri::finalize_ixn_event(ixn).map_err(AnchorError::from)
123}
124
125fn canonicalize_and_said<T: serde::Serialize>(data: &T) -> Result<Said, AnchorError> {
126    let canonical =
127        json_canon::to_string(data).map_err(|e| AnchorError::Serialization(e.to_string()))?;
128    let value: serde_json::Value =
129        serde_json::from_str(&canonical).map_err(|e| AnchorError::Serialization(e.to_string()))?;
130    compute_said(&value).map_err(|e| AnchorError::Serialization(e.to_string()))
131}
132
133fn sign_ixn(
134    ixn: &IxnEvent,
135    signer: &dyn SecureSigner,
136    signer_alias: &KeyAlias,
137    passphrase_provider: &dyn PassphraseProvider,
138) -> Result<Vec<u8>, AnchorError> {
139    let canonical_event = serialize_for_signing(&Event::Ixn(ixn.clone()))?;
140    signer
141        .sign_with_alias(signer_alias, passphrase_provider, &canonical_event)
142        .map_err(|e| AnchorError::Signing(e.to_string()))
143}
144
145// ── GitKel-based (unit tests, direct git access) ─────────────────────────────
146
147/// Anchor an attestation in the KEL via an ixn seal (GitKel storage).
148///
149/// # Returns
150/// * `(attestation_said, finalized_ixn)` — the attestation SAID and the appended IXN event
151#[allow(clippy::too_many_arguments)]
152pub fn anchor_and_persist<T: serde::Serialize>(
153    kel: &GitKel,
154    signer: &dyn SecureSigner,
155    signer_alias: &KeyAlias,
156    passphrase_provider: &dyn PassphraseProvider,
157    controller_prefix: &Prefix,
158    attestation: &T,
159    now: chrono::DateTime<chrono::Utc>,
160) -> Result<(Said, IxnEvent), AnchorError> {
161    if !kel.exists() {
162        return Err(AnchorError::NotFound(
163            controller_prefix.as_str().to_string(),
164        ));
165    }
166
167    let events = kel.get_events()?;
168    let state = validate_kel(&events)?;
169
170    if !state.can_emit_ixn() {
171        return Err(AnchorError::IxnForbidden(ixn_forbidden_reason(&state)));
172    }
173
174    let attestation_said = canonicalize_and_said(attestation)?;
175    let ixn = build_anchor_ixn(&attestation_said, controller_prefix, &state)?;
176    // Sign and verify (fail-fast). GitKel doesn't support CESR attachments,
177    // so the signature is verified but not stored. Production code should
178    // use try_stage_anchor + anchor_and_persist_via_backend which stores
179    // the signature as a CESR attachment.
180    let _sig = sign_ixn(&ixn, signer, signer_alias, passphrase_provider)?;
181
182    kel.append(&Event::Ixn(ixn.clone()), now)?;
183
184    Ok((attestation_said, ixn))
185}
186
187// ── RegistryBackend-based (SDK orchestration) ────────────────────────────────
188
189/// Stage an ixn anchor seal in the batch WITHOUT committing.
190///
191/// The caller is responsible for calling `backend.commit_batch(batch)` after
192/// this function returns. This separation ensures the caller can always commit
193/// the batch (deterministic) regardless of whether the ixn was successfully
194/// staged.
195#[allow(clippy::too_many_arguments)]
196pub fn try_stage_anchor<T: serde::Serialize>(
197    backend: &dyn crate::storage::registry::backend::RegistryBackend,
198    signer: &dyn SecureSigner,
199    signer_alias: &KeyAlias,
200    passphrase_provider: &dyn PassphraseProvider,
201    controller_prefix: &Prefix,
202    attestation: &T,
203    batch: &mut crate::storage::registry::backend::AtomicWriteBatch,
204) -> Result<(Said, IxnEvent), AnchorError> {
205    let state = backend
206        .get_key_state(controller_prefix)
207        .map_err(|e| AnchorError::NotFound(e.to_string()))?;
208
209    if !state.can_emit_ixn() {
210        return Err(AnchorError::IxnForbidden(ixn_forbidden_reason(&state)));
211    }
212
213    let attestation_said = canonicalize_and_said(attestation)?;
214    let ixn = build_anchor_ixn(&attestation_said, controller_prefix, &state)?;
215    let sig = sign_ixn(&ixn, signer, signer_alias, passphrase_provider)?;
216
217    let attachment = auths_keri::serialize_attachment(&[auths_keri::IndexedSignature {
218        index: 0,
219        prior_index: None,
220        sig,
221    }])
222    .map_err(|e| AnchorError::Serialization(e.to_string()))?;
223
224    batch.stage_event(
225        controller_prefix.clone(),
226        Event::Ixn(ixn.clone()),
227        attachment,
228    );
229
230    Ok((attestation_said, ixn))
231}
232
233/// Stage anchor + collect witness receipts + commit batch.
234///
235/// Witness receipts are collected BEFORE committing to git. Under
236/// `WitnessPolicy::Enforce`, if receipt collection fails the ixn is never
237/// committed — no orphaned unwitnessed events in the KEL.
238#[allow(clippy::too_many_arguments)]
239pub fn anchor_and_persist_via_backend<T: serde::Serialize>(
240    backend: &dyn crate::storage::registry::backend::RegistryBackend,
241    signer: &dyn SecureSigner,
242    signer_alias: &KeyAlias,
243    passphrase_provider: &dyn PassphraseProvider,
244    controller_prefix: &Prefix,
245    attestation: &T,
246    batch: &mut crate::storage::registry::backend::AtomicWriteBatch,
247    witness_params: &crate::witness_config::WitnessParams<'_>,
248    now: chrono::DateTime<chrono::Utc>,
249) -> Result<(Said, IxnEvent), AnchorError> {
250    let result = try_stage_anchor(
251        backend,
252        signer,
253        signer_alias,
254        passphrase_provider,
255        controller_prefix,
256        attestation,
257        batch,
258    )?;
259
260    let (_, ref ixn) = result;
261
262    #[cfg(feature = "witness-client")]
263    if let crate::witness_config::WitnessParams::Enabled { config, repo_path } = witness_params {
264        let canonical = serialize_for_signing(&Event::Ixn(ixn.clone()))?;
265        super::witness_integration::collect_and_store_receipts(
266            repo_path,
267            controller_prefix,
268            &ixn.d,
269            &canonical,
270            config,
271            now,
272        )
273        .map_err(|e| AnchorError::WitnessQuorumNotMet(e.to_string()))?;
274    }
275
276    #[cfg(not(feature = "witness-client"))]
277    {
278        let _ = (witness_params, &ixn, now);
279    }
280
281    backend
282        .commit_batch(batch)
283        .map_err(|e| AnchorError::Kel(KelError::Serialization(e.to_string())))?;
284
285    Ok(result)
286}
287
288// ── KEL walk ─────────────────────────────────────────────────────────────────
289
290/// Collect all digest SAIDs anchored in the KEL up to (and including) `at_seq`.
291///
292/// Walks the KEL and extracts every digest seal from ixn events. Returns
293/// `(sequence, said)` pairs in event order. Callers resolve the SAIDs to
294/// attestation blobs to determine which devices are authorized.
295pub fn resolve_anchored_saids_via_backend(
296    backend: &dyn crate::storage::registry::backend::RegistryBackend,
297    controller_prefix: &Prefix,
298    at_seq: Option<u128>,
299) -> Result<Vec<(u128, Said)>, AnchorError> {
300    let mut results = Vec::new();
301    backend
302        .visit_events(controller_prefix, 0, &mut |event| {
303            if let Some(max) = at_seq
304                && event.sequence().value() > max
305            {
306                return std::ops::ControlFlow::Break(());
307            }
308            if let Event::Ixn(ixn) = event {
309                for seal in &ixn.a {
310                    if let Some(digest) = seal.digest_value() {
311                        results.push((ixn.s.value(), digest.clone()));
312                    }
313                }
314            }
315            std::ops::ControlFlow::Continue(())
316        })
317        .map_err(|e| AnchorError::NotFound(e.to_string()))?;
318
319    Ok(results)
320}
321
322// ── Verification ─────────────────────────────────────────────────────────────
323
324/// Find the IXN event that anchors a specific data digest.
325pub fn find_anchor_event(
326    repo: &Repository,
327    prefix: &Prefix,
328    data_digest: &str,
329) -> Result<Option<IxnEvent>, AnchorError> {
330    let kel = GitKel::new(repo, prefix.as_str());
331    if !kel.exists() {
332        return Err(AnchorError::NotFound(prefix.as_str().to_string()));
333    }
334
335    let events = kel.get_events()?;
336
337    for event in events {
338        if let Event::Ixn(ixn) = event {
339            for seal in &ixn.a {
340                if seal.digest_value().map(|d| d.as_str()) == Some(data_digest) {
341                    return Ok(Some(ixn));
342                }
343            }
344        }
345    }
346
347    Ok(None)
348}
349
350/// Verify that data is properly anchored in a KEL.
351pub fn verify_anchor<T: serde::Serialize>(
352    repo: &Repository,
353    prefix: &Prefix,
354    data: &T,
355) -> Result<AnchorVerification, AnchorError> {
356    let data_digest = canonicalize_and_said(data)?;
357    verify_anchor_by_digest(repo, prefix, data_digest.as_str())
358}
359
360/// Verify anchor by digest (when you already have the digest).
361pub fn verify_anchor_by_digest(
362    repo: &Repository,
363    prefix: &Prefix,
364    data_digest: &str,
365) -> Result<AnchorVerification, AnchorError> {
366    let kel = GitKel::new(repo, prefix.as_str());
367    if !kel.exists() {
368        return Err(AnchorError::NotFound(prefix.as_str().to_string()));
369    }
370
371    let anchor = find_anchor_event(repo, prefix, data_digest)?;
372
373    match anchor {
374        Some(ixn) => {
375            let events = kel.get_events()?;
376            let anchor_seq = ixn.s.value();
377            let events_subset: Vec<_> = events
378                .into_iter()
379                .take_while(|e| e.sequence().value() <= anchor_seq)
380                .collect();
381            let state = validate_kel(&events_subset)?;
382
383            Ok(AnchorVerification {
384                status: AnchorStatus::Anchored,
385                anchor_said: Some(ixn.d),
386                anchor_sequence: Some(anchor_seq),
387                signing_key: state.current_key().map(|s| s.to_string()),
388            })
389        }
390        None => Ok(AnchorVerification {
391            status: AnchorStatus::NotAnchored,
392            anchor_said: None,
393            anchor_sequence: None,
394            signing_key: None,
395        }),
396    }
397}
398
399/// Verify that an attestation is properly anchored, extracting the issuer DID.
400pub fn verify_attestation_anchor_by_issuer<T: serde::Serialize>(
401    repo: &Repository,
402    issuer_did: &str,
403    attestation: &T,
404) -> Result<AnchorVerification, AnchorError> {
405    let prefix: Prefix =
406        parse_did_keri(issuer_did).map_err(|e| AnchorError::InvalidDid(e.to_string()))?;
407    verify_anchor(repo, &prefix, attestation)
408}
409
410#[cfg(test)]
411#[allow(clippy::disallowed_methods)]
412mod tests {
413    use super::*;
414    use crate::keri::{Prefix, create_keri_identity_with_curve};
415    use auths_core::crypto::signer::encrypt_keypair;
416    use auths_core::signing::StorageSigner;
417    use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyRole, KeyStorage};
418    use auths_core::testing::{IsolatedKeychainHandle, TestPassphraseProvider};
419    use serde::{Deserialize, Serialize};
420    use tempfile::TempDir;
421
422    const TEST_PASSPHRASE: &str = "Test-passphrase1!";
423
424    fn setup_repo() -> (TempDir, Repository) {
425        let dir = TempDir::new().unwrap();
426        let repo = Repository::init(dir.path()).unwrap();
427
428        let mut config = repo.config().unwrap();
429        config.set_str("user.name", "Test User").unwrap();
430        config.set_str("user.email", "test@example.com").unwrap();
431
432        (dir, repo)
433    }
434
435    #[derive(Debug, Serialize, Deserialize)]
436    struct TestAttestation {
437        issuer: String,
438        subject: String,
439        capabilities: Vec<String>,
440    }
441
442    fn make_test_attestation(issuer: &str, subject: &str) -> TestAttestation {
443        TestAttestation {
444            issuer: issuer.to_string(),
445            subject: subject.to_string(),
446            capabilities: vec!["sign-commit".to_string()],
447        }
448    }
449
450    struct TestSetup {
451        _dir: TempDir,
452        repo: Repository,
453        prefix: Prefix,
454        issuer_did: String,
455        signer: StorageSigner<IsolatedKeychainHandle>,
456        alias: KeyAlias,
457        provider: TestPassphraseProvider,
458    }
459
460    fn setup_identity() -> TestSetup {
461        let (dir, repo) = setup_repo();
462        let keychain = IsolatedKeychainHandle::new();
463
464        let init = create_keri_identity_with_curve(
465            &repo,
466            None,
467            chrono::Utc::now(),
468            auths_crypto::CurveType::default(),
469        )
470        .unwrap();
471
472        let issuer_did = format!("did:keri:{}", init.prefix);
473        let alias = KeyAlias::new_unchecked("test-anchor-key");
474        let identity_did = IdentityDID::new_unchecked(&issuer_did);
475
476        let encrypted = encrypt_keypair(init.current_keypair_pkcs8.as_ref(), TEST_PASSPHRASE)
477            .expect("encrypt keypair");
478        keychain
479            .store_key(&alias, &identity_did, KeyRole::Primary, &encrypted)
480            .expect("store key");
481
482        let signer = StorageSigner::new(keychain);
483        let provider = TestPassphraseProvider::new(TEST_PASSPHRASE);
484
485        TestSetup {
486            _dir: dir,
487            repo,
488            prefix: init.prefix,
489            issuer_did,
490            signer,
491            alias,
492            provider,
493        }
494    }
495
496    fn anchor(s: &TestSetup, attestation: &TestAttestation) -> (Said, IxnEvent) {
497        let kel = GitKel::new(&s.repo, s.prefix.as_str());
498        anchor_and_persist(
499            &kel,
500            &s.signer,
501            &s.alias,
502            &s.provider,
503            &s.prefix,
504            attestation,
505            chrono::Utc::now(),
506        )
507        .unwrap()
508    }
509
510    #[test]
511    fn creates_ixn_event() {
512        let s = setup_identity();
513        let att = make_test_attestation(&s.issuer_did, "did:key:device123");
514        let (att_said, ixn) = anchor(&s, &att);
515
516        let kel = GitKel::new(&s.repo, s.prefix.as_str());
517        let events = kel.get_events().unwrap();
518        assert_eq!(events.len(), 2);
519        assert!(events[0].is_inception());
520        assert!(events[1].is_interaction());
521
522        assert_eq!(ixn.a.len(), 1);
523        assert_eq!(ixn.a[0].digest_value().unwrap(), &att_said);
524        assert_ne!(ixn.d, Said::default());
525    }
526
527    #[test]
528    fn find_anchor_locates_attestation() {
529        let s = setup_identity();
530        let att = make_test_attestation(&s.issuer_did, "did:key:device123");
531        let (att_said, _) = anchor(&s, &att);
532
533        let found = find_anchor_event(&s.repo, &s.prefix, att_said.as_str()).unwrap();
534        assert!(found.is_some());
535        assert_eq!(found.unwrap().a[0].digest_value().unwrap(), &att_said);
536    }
537
538    #[test]
539    fn verify_anchor_works() {
540        let s = setup_identity();
541        let att = make_test_attestation(&s.issuer_did, "did:key:device123");
542        anchor(&s, &att);
543
544        let verification = verify_anchor(&s.repo, &s.prefix, &att).unwrap();
545        assert_eq!(verification.status, AnchorStatus::Anchored);
546        assert!(verification.anchor_said.is_some());
547        assert_eq!(verification.anchor_sequence, Some(1));
548        assert!(verification.signing_key.is_some());
549    }
550
551    #[test]
552    fn unanchored_attestation_not_found() {
553        let s = setup_identity();
554        let att = make_test_attestation(&s.issuer_did, "did:key:device123");
555
556        let verification = verify_anchor(&s.repo, &s.prefix, &att).unwrap();
557        assert_eq!(verification.status, AnchorStatus::NotAnchored);
558        assert!(verification.anchor_said.is_none());
559    }
560
561    #[test]
562    fn multiple_anchors_work() {
563        let s = setup_identity();
564        let att1 = make_test_attestation(&s.issuer_did, "did:key:device1");
565        let att2 = make_test_attestation(&s.issuer_did, "did:key:device2");
566
567        let (said1, _) = anchor(&s, &att1);
568        let (said2, _) = anchor(&s, &att2);
569        assert_ne!(said1, said2);
570
571        let v1 = verify_anchor(&s.repo, &s.prefix, &att1).unwrap();
572        let v2 = verify_anchor(&s.repo, &s.prefix, &att2).unwrap();
573        assert_eq!(v1.status, AnchorStatus::Anchored);
574        assert_eq!(v2.status, AnchorStatus::Anchored);
575        assert_eq!(v1.anchor_sequence, Some(1));
576        assert_eq!(v2.anchor_sequence, Some(2));
577    }
578
579    #[test]
580    fn verify_by_issuer_did() {
581        let s = setup_identity();
582        let att = make_test_attestation(&s.issuer_did, "did:key:device123");
583        anchor(&s, &att);
584
585        let verification =
586            verify_attestation_anchor_by_issuer(&s.repo, &s.issuer_did, &att).unwrap();
587        assert_eq!(verification.status, AnchorStatus::Anchored);
588    }
589
590    #[test]
591    fn anchor_not_found_for_missing_kel() {
592        let s = setup_identity();
593        let att = make_test_attestation("did:keri:ENotExist", "did:key:device");
594        let fake_prefix = Prefix::new_unchecked("ENotExist".to_string());
595        let kel = GitKel::new(&s.repo, fake_prefix.as_str());
596        let result = anchor_and_persist(
597            &kel,
598            &s.signer,
599            &s.alias,
600            &s.provider,
601            &fake_prefix,
602            &att,
603            chrono::Utc::now(),
604        );
605        assert!(matches!(result, Err(AnchorError::NotFound(_))));
606    }
607
608    #[test]
609    fn canonical_said_is_deterministic() {
610        let s = setup_identity();
611        let att = make_test_attestation(&s.issuer_did, "did:key:device123");
612
613        let kel = GitKel::new(&s.repo, s.prefix.as_str());
614        let (att_said, _) = anchor_and_persist(
615            &kel,
616            &s.signer,
617            &s.alias,
618            &s.provider,
619            &s.prefix,
620            &att,
621            chrono::Utc::now(),
622        )
623        .unwrap();
624
625        let canonical = json_canon::to_string(&att).unwrap();
626        let canonical_value: serde_json::Value = serde_json::from_str(&canonical).unwrap();
627        let expected_said = compute_said(&canonical_value).unwrap();
628        assert_eq!(att_said, expected_said);
629    }
630}