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
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#[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 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#[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#[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
288pub 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
322pub 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
350pub 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
360pub 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
399pub 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}