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