1use 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#[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#[derive(Debug, Clone)]
86pub struct AnchorVerification {
87 pub status: AnchorStatus,
89
90 pub anchor_said: Option<Said>,
92
93 pub anchor_sequence: Option<u128>,
95
96 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
133pub 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#[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 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#[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#[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
295pub 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
329pub 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
357pub 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
367pub 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
406pub 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}