1use chrono::{DateTime, Duration, Utc};
7use ring::signature::{ECDSA_P256_SHA256_ASN1, ED25519, UnparsedPublicKey};
8
9use crate::bundle::{
10 BundleVerificationReport, CheckpointStatus, DelegationStatus, InclusionStatus, NamespaceStatus,
11 SignatureStatus, WitnessStatus,
12};
13use crate::checkpoint::SignedCheckpoint;
14use crate::entry::{EntryBody, EntryType};
15use crate::merkle::hash_leaf;
16use crate::{OfflineBundle, TrustRoot};
17use auths_verifier::{Capability, IdentityDID};
18
19const STALE_BUNDLE_DAYS: i64 = 90;
20
21pub fn verify_bundle(
40 bundle: &OfflineBundle,
41 trust_root: &TrustRoot,
42 now: DateTime<Utc>,
43) -> BundleVerificationReport {
44 let signature = verify_signature(bundle);
45 let inclusion = verify_inclusion_proof(bundle);
46 let checkpoint = verify_checkpoint(&bundle.signed_checkpoint, trust_root);
47 let witnesses = verify_witnesses(&bundle.signed_checkpoint, trust_root);
48 let delegation = verify_delegation_chain(bundle);
49 let namespace = derive_namespace_status(&delegation, bundle);
50
51 let mut warnings = Vec::new();
52 check_staleness(&bundle.signed_checkpoint, now, &mut warnings);
53
54 BundleVerificationReport {
55 signature,
56 inclusion,
57 checkpoint,
58 witnesses,
59 namespace,
60 delegation,
61 warnings,
62 }
63}
64
65fn resolve_actor_public_key(bundle: &OfflineBundle) -> Option<[u8; 32]> {
66 let actor_did = bundle.entry.content.actor_did.as_str();
67
68 if actor_did.starts_with("did:key:z") {
69 return auths_crypto::did_key_decode(actor_did)
70 .ok()
71 .and_then(|decoded| match decoded {
72 auths_crypto::DecodedDidKey::Ed25519(pk) => Some(pk),
73 auths_crypto::DecodedDidKey::P256(_) => None,
74 });
75 }
76
77 if actor_did.starts_with("did:keri:") {
78 for link in &bundle.delegation_chain {
79 if link.link_type == EntryType::DeviceBind
80 && let EntryBody::DeviceBind {
81 ref device_did,
82 ref public_key,
83 } = link.entry.content.body
84 && device_did.as_str() == actor_did
85 {
86 return Some(*public_key.as_bytes());
87 }
88 }
89 }
90
91 None
92}
93
94fn verify_signature(bundle: &OfflineBundle) -> SignatureStatus {
95 let public_key_bytes = match resolve_actor_public_key(bundle) {
96 Some(pk) => pk,
97 None => {
98 return SignatureStatus::Failed {
99 reason: format!(
100 "could not resolve public key for actor DID: {}",
101 bundle.entry.content.actor_did
102 ),
103 };
104 }
105 };
106
107 let canonical = match bundle.entry.content.canonicalize() {
108 Ok(c) => c,
109 Err(e) => {
110 return SignatureStatus::Failed {
111 reason: format!("canonicalization failed: {e}"),
112 };
113 }
114 };
115
116 let peer_key = UnparsedPublicKey::new(&ED25519, &public_key_bytes);
117 match peer_key.verify(&canonical, bundle.entry.actor_sig.as_bytes()) {
118 Ok(()) => SignatureStatus::Verified,
119 Err(_) => SignatureStatus::Failed {
120 reason: "Ed25519 signature verification failed".into(),
121 },
122 }
123}
124
125fn verify_inclusion_proof(bundle: &OfflineBundle) -> InclusionStatus {
126 let leaf_data = match bundle.entry.leaf_data() {
127 Ok(d) => d,
128 Err(e) => {
129 return InclusionStatus::Failed {
130 reason: format!("leaf data serialization failed: {e}"),
131 };
132 }
133 };
134 let leaf_hash = hash_leaf(&leaf_data);
135
136 let proof = &bundle.inclusion_proof;
137 if let Err(e) = crate::merkle::verify_inclusion(
138 &leaf_hash,
139 proof.index,
140 proof.size,
141 &proof.hashes,
142 &proof.root,
143 ) {
144 return InclusionStatus::Failed {
145 reason: format!("Merkle inclusion failed: {e}"),
146 };
147 }
148
149 if proof.root != bundle.signed_checkpoint.checkpoint.root {
150 return InclusionStatus::Failed {
151 reason: "inclusion proof root does not match checkpoint root".into(),
152 };
153 }
154
155 InclusionStatus::Verified
156}
157
158fn verify_checkpoint(signed: &SignedCheckpoint, trust_root: &TrustRoot) -> CheckpointStatus {
159 if signed.checkpoint.origin != trust_root.log_origin {
160 return CheckpointStatus::InvalidSignature;
161 }
162
163 let note_body = signed.checkpoint.to_note_body();
164
165 match trust_root.signature_algorithm {
166 auths_verifier::SignatureAlgorithm::Ed25519 => {
167 match signed.verify_log_signature(&trust_root.log_public_key) {
172 Ok(()) => CheckpointStatus::Verified,
173 Err(_) => CheckpointStatus::InvalidSignature,
174 }
175 }
176 auths_verifier::SignatureAlgorithm::EcdsaP256 => {
177 let Some(ecdsa_sig) = &signed.ecdsa_checkpoint_signature else {
190 return CheckpointStatus::MissingEcdsaSignature;
191 };
192 let Some(ecdsa_pk) = &signed.ecdsa_checkpoint_key else {
193 return CheckpointStatus::MissingEcdsaKey;
194 };
195 if let Some(pinned) = trust_root.ecdsa_log_public_key_der.as_deref()
200 && pinned != ecdsa_pk.as_der()
201 {
202 return CheckpointStatus::InvalidSignature;
203 }
204 let Some(point) = ecdsa_pk.as_sec1_uncompressed() else {
207 return CheckpointStatus::InvalidSignature;
208 };
209 let peer_key = UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, point);
210 match peer_key.verify(note_body.as_bytes(), ecdsa_sig.as_der()) {
211 Ok(()) => CheckpointStatus::Verified,
212 Err(_) => CheckpointStatus::InvalidSignature,
213 }
214 }
215 }
216}
217
218pub fn verify_checkpoint_signature(
232 signed: &SignedCheckpoint,
233 trust_root: &TrustRoot,
234) -> CheckpointStatus {
235 verify_checkpoint(signed, trust_root)
236}
237
238pub fn verify_witness_cosignatures(
256 signed: &SignedCheckpoint,
257 trust_root: &TrustRoot,
258) -> WitnessStatus {
259 verify_witnesses(signed, trust_root)
260}
261
262fn verify_witnesses(signed: &SignedCheckpoint, trust_root: &TrustRoot) -> WitnessStatus {
263 if trust_root.witnesses.is_empty() {
264 return WitnessStatus::NotProvided;
265 }
266
267 let note_body = signed.checkpoint.to_note_body();
268 let required = trust_root.witnesses.len() / 2 + 1;
269 let mut verified = 0usize;
270 let mut cosigner_attrs = Vec::new();
274
275 for cosig in &signed.witnesses {
276 let trusted = trust_root.witnesses.iter().find(|w| {
277 use subtle::ConstantTimeEq;
278 w.public_key
279 .as_bytes()
280 .ct_eq(cosig.witness_public_key.as_bytes())
281 .into()
282 });
283
284 if let Some(witness) = trusted {
285 let peer_key = UnparsedPublicKey::new(&ED25519, cosig.witness_public_key.as_bytes());
286 if peer_key
287 .verify(note_body.as_bytes(), cosig.signature.as_bytes())
288 .is_ok()
289 {
290 verified += 1;
291 if let Some(attrs) = witness.operator_attributes() {
294 cosigner_attrs.push(attrs);
295 }
296 }
297 }
298 }
299
300 if verified < required {
301 return WitnessStatus::Insufficient { verified, required };
302 }
303
304 let independence = auths_keri::witness::independence::spans_distinct(
308 &cosigner_attrs,
309 &trust_root.independence_policy,
310 );
311 if independence.independent {
312 WitnessStatus::Quorum { verified, required }
313 } else {
314 WitnessStatus::NotIndependent {
315 verified,
316 required,
317 shortfalls: independence.shortfalls,
318 }
319 }
320}
321
322fn check_staleness(signed: &SignedCheckpoint, now: DateTime<Utc>, warnings: &mut Vec<String>) {
323 #[allow(clippy::expect_used)] let stale_threshold =
325 Duration::try_days(STALE_BUNDLE_DAYS).expect("STALE_BUNDLE_DAYS is a small constant");
326 if now - signed.checkpoint.timestamp > stale_threshold {
327 warnings.push(format!(
328 "bundle checkpoint is older than {} days",
329 STALE_BUNDLE_DAYS
330 ));
331 }
332}
333
334fn validate_chain_link_order(
335 chain: &[crate::bundle::DelegationChainLink],
336) -> Option<DelegationStatus> {
337 if chain[0].link_type != EntryType::DeviceBind {
338 return Some(DelegationStatus::ChainBroken {
339 reason: format!(
340 "link[0] expected type {:?}, got {:?}",
341 EntryType::DeviceBind,
342 chain[0].link_type
343 ),
344 });
345 }
346
347 let allowed_order = [
348 EntryType::OrgAddMember,
349 EntryType::NamespaceClaim,
350 EntryType::NamespaceDelegate,
351 ];
352
353 let mut order_idx = 0;
354 for (i, link) in chain.iter().enumerate().skip(1) {
355 while order_idx < allowed_order.len() && link.link_type != allowed_order[order_idx] {
356 order_idx += 1;
357 }
358 if order_idx >= allowed_order.len() {
359 return Some(DelegationStatus::ChainBroken {
360 reason: format!(
361 "link[{i}] unexpected type {:?} at this position",
362 link.link_type
363 ),
364 });
365 }
366 if link.link_type == EntryType::NamespaceDelegate {
367 } else {
369 order_idx += 1;
370 }
371 }
372
373 let has_namespace_claim = chain
374 .iter()
375 .any(|l| l.link_type == EntryType::NamespaceClaim);
376 let has_namespace_delegate = chain
377 .iter()
378 .any(|l| l.link_type == EntryType::NamespaceDelegate);
379 if has_namespace_delegate && !has_namespace_claim {
380 return Some(DelegationStatus::ChainBroken {
381 reason: "NamespaceDelegate requires a preceding NamespaceClaim".into(),
382 });
383 }
384
385 if !has_namespace_claim
386 && !chain
387 .iter()
388 .skip(1)
389 .any(|l| l.link_type == EntryType::OrgAddMember)
390 {
391 return Some(DelegationStatus::ChainBroken {
392 reason: "chain must contain at least OrgAddMember or NamespaceClaim after DeviceBind"
393 .into(),
394 });
395 }
396
397 None
398}
399
400fn verify_link_inclusion_proofs(
401 chain: &[crate::bundle::DelegationChainLink],
402 checkpoint_root: &crate::types::MerkleHash,
403) -> Option<DelegationStatus> {
404 let mut sequences: Vec<u128> = chain.iter().map(|l| l.entry.sequence).collect();
405 sequences.sort_unstable();
406 sequences.dedup();
407 if sequences.len() != chain.len() {
408 return Some(DelegationStatus::ChainBroken {
409 reason: "duplicate sequence numbers in delegation chain".into(),
410 });
411 }
412
413 for (i, link) in chain.iter().enumerate() {
414 let leaf_data = match link.entry.leaf_data() {
415 Ok(d) => d,
416 Err(e) => {
417 return Some(DelegationStatus::ChainBroken {
418 reason: format!("link[{i}] leaf data failed: {e}"),
419 });
420 }
421 };
422 let leaf_hash = hash_leaf(&leaf_data);
423 let proof = &link.inclusion_proof;
424 if let Err(e) = crate::merkle::verify_inclusion(
425 &leaf_hash,
426 proof.index,
427 proof.size,
428 &proof.hashes,
429 &proof.root,
430 ) {
431 return Some(DelegationStatus::ChainBroken {
432 reason: format!("link[{i}] inclusion proof failed: {e}"),
433 });
434 }
435 if &proof.root != checkpoint_root {
436 return Some(DelegationStatus::ChainBroken {
437 reason: format!("link[{i}] proof root does not match checkpoint"),
438 });
439 }
440 }
441
442 None
443}
444
445fn extract_namespace_from_entry(body: &EntryBody) -> Option<(&str, &str)> {
446 match body {
447 EntryBody::NamespaceClaim {
448 ecosystem,
449 package_name,
450 ..
451 }
452 | EntryBody::NamespaceDelegate {
453 ecosystem,
454 package_name,
455 ..
456 }
457 | EntryBody::NamespaceTransfer {
458 ecosystem,
459 package_name,
460 ..
461 } => Some((ecosystem.as_str(), package_name.as_str())),
462 _ => None,
463 }
464}
465
466fn verify_delegation_chain(bundle: &OfflineBundle) -> DelegationStatus {
467 if bundle.delegation_chain.is_empty() {
468 return DelegationStatus::NoDelegationData;
469 }
470
471 let chain = &bundle.delegation_chain;
472
473 if chain.len() < 2 {
474 return DelegationStatus::ChainBroken {
475 reason: format!("chain must have at least 2 links, got {}", chain.len()),
476 };
477 }
478
479 if let Some(broken) = validate_chain_link_order(chain) {
480 return broken;
481 }
482
483 let checkpoint_root = &bundle.signed_checkpoint.checkpoint.root;
484 if let Some(broken) = verify_link_inclusion_proofs(chain, checkpoint_root) {
485 return broken;
486 }
487
488 let device_did = match &chain[0].entry.content.body {
489 EntryBody::DeviceBind { device_did, .. } => device_did.clone(),
490 _ => {
491 return DelegationStatus::ChainBroken {
492 reason: "link[0] body is not DeviceBind".into(),
493 };
494 }
495 };
496
497 let identity_did = match IdentityDID::parse(chain[0].entry.content.actor_did.as_str()) {
498 Ok(did) => did,
499 Err(err) => {
500 return DelegationStatus::ChainBroken {
501 reason: format!("link[0] DeviceBind actor_did is not a valid identity DID: {err}"),
502 };
503 }
504 };
505
506 let org_add_member = chain
507 .iter()
508 .enumerate()
509 .find(|(_, l)| l.link_type == EntryType::OrgAddMember);
510
511 let namespace_claim = chain
512 .iter()
513 .enumerate()
514 .find(|(_, l)| l.link_type == EntryType::NamespaceClaim);
515
516 let (member_did, member_role, org_did) = if let Some((idx, link)) = org_add_member {
517 match &link.entry.content.body {
518 EntryBody::OrgAddMember {
519 member_did,
520 role,
521 capabilities,
522 ..
523 } => {
524 let bundle_is_namespace_op =
525 extract_namespace_from_entry(&bundle.entry.content.body).is_some();
526 if bundle_is_namespace_op && !capabilities.contains(&Capability::sign_release()) {
527 return DelegationStatus::ChainBroken {
528 reason: format!(
529 "link[{idx}] OrgAddMember lacks sign_release capability required for namespace operations"
530 ),
531 };
532 }
533
534 let org = match IdentityDID::parse(link.entry.content.actor_did.as_str()) {
535 Ok(did) => did,
536 Err(err) => {
537 return DelegationStatus::ChainBroken {
538 reason: format!(
539 "link[{idx}] OrgAddMember actor_did is not a valid identity DID: {err}"
540 ),
541 };
542 }
543 };
544 (member_did.clone(), *role, org)
545 }
546 _ => {
547 return DelegationStatus::ChainBroken {
548 reason: format!("link[{idx}] body is not OrgAddMember"),
549 };
550 }
551 }
552 } else {
553 let owner_did = match IdentityDID::parse(chain[0].entry.content.actor_did.as_str()) {
555 Ok(did) => did,
556 Err(err) => {
557 return DelegationStatus::ChainBroken {
558 reason: format!(
559 "link[0] DeviceBind actor_did is not a valid identity DID: {err}"
560 ),
561 };
562 }
563 };
564 (owner_did.clone(), auths_verifier::Role::Admin, owner_did)
565 };
566
567 if member_did.as_str() != identity_did.as_str() {
568 return DelegationStatus::ChainBroken {
569 reason: format!(
570 "DID connectivity broken: OrgAddMember member_did ({}) != DeviceBind actor_did ({})",
571 member_did, identity_did
572 ),
573 };
574 }
575
576 if let Some((ns_idx, ns_link)) = namespace_claim {
577 if let EntryBody::NamespaceClaim {
578 ecosystem: claim_ecosystem,
579 package_name: claim_package,
580 ..
581 } = &ns_link.entry.content.body
582 {
583 if org_add_member.is_some() {
584 let claim_actor = match IdentityDID::parse(ns_link.entry.content.actor_did.as_str())
585 {
586 Ok(did) => did,
587 Err(err) => {
588 return DelegationStatus::ChainBroken {
589 reason: format!(
590 "link[{ns_idx}] NamespaceClaim actor_did is not a valid identity DID: {err}"
591 ),
592 };
593 }
594 };
595 if claim_actor.as_str() != org_did.as_str() {
596 return DelegationStatus::ChainBroken {
597 reason: format!(
598 "link[{ns_idx}] NamespaceClaim actor_did ({}) != org_did ({})",
599 claim_actor, org_did
600 ),
601 };
602 }
603 }
604
605 if let Some((bundle_ecosystem, bundle_package)) =
606 extract_namespace_from_entry(&bundle.entry.content.body)
607 && (claim_ecosystem != bundle_ecosystem || claim_package != bundle_package)
608 {
609 return DelegationStatus::ChainBroken {
610 reason: format!(
611 "link[{ns_idx}] NamespaceClaim namespace ({}/{}) does not match bundle entry ({}/{})",
612 claim_ecosystem, claim_package, bundle_ecosystem, bundle_package
613 ),
614 };
615 }
616 } else {
617 return DelegationStatus::ChainBroken {
618 reason: format!("link[{ns_idx}] body is not NamespaceClaim"),
619 };
620 }
621 }
622
623 DelegationStatus::ChainVerified {
624 org_did,
625 member_did,
626 member_role,
627 device_did,
628 }
629}
630
631fn derive_namespace_status(
632 delegation: &DelegationStatus,
633 bundle: &OfflineBundle,
634) -> NamespaceStatus {
635 match delegation {
636 DelegationStatus::ChainVerified { .. } => {
637 let has_namespace_delegate = bundle
638 .delegation_chain
639 .iter()
640 .any(|link| link.link_type == EntryType::NamespaceDelegate);
641 let has_namespace_claim = bundle
642 .delegation_chain
643 .iter()
644 .any(|link| link.link_type == EntryType::NamespaceClaim);
645 if has_namespace_delegate || has_namespace_claim {
646 NamespaceStatus::Authorized
647 } else {
648 NamespaceStatus::Owned
649 }
650 }
651 DelegationStatus::Direct => NamespaceStatus::Owned,
652 DelegationStatus::NoDelegationData => NamespaceStatus::Owned,
653 DelegationStatus::ChainBroken { .. } => NamespaceStatus::Unauthorized,
654 }
655}
656
657#[cfg(test)]
658#[allow(clippy::disallowed_methods)]
659mod tests {
660 use super::*;
661 use crate::TrustRootWitness;
662 use crate::bundle::DelegationChainLink;
663 use crate::checkpoint::{Checkpoint, WitnessCosignature};
664 use crate::entry::{Entry, EntryContent};
665 use crate::merkle::compute_root;
666 use crate::proof::InclusionProof;
667 use crate::types::LogOrigin;
668 use auths_verifier::{CanonicalDid, Ed25519PublicKey, Ed25519Signature};
669 use ring::signature::{Ed25519KeyPair, KeyPair};
670
671 fn fixed_now() -> DateTime<Utc> {
672 chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
673 .unwrap()
674 .with_timezone(&Utc)
675 }
676
677 fn fixed_ts() -> DateTime<Utc> {
678 chrono::DateTime::parse_from_rfc3339("2025-06-15T00:00:00Z")
679 .unwrap()
680 .with_timezone(&Utc)
681 }
682
683 struct TestFixture {
684 log_keypair: Ed25519KeyPair,
685 log_public_key: [u8; 32],
686 actor_keypair: Ed25519KeyPair,
687 actor_public_key: [u8; 32],
688 actor_did: String,
689 trust_root: TrustRoot,
690 }
691
692 fn setup() -> TestFixture {
693 let log_keypair = Ed25519KeyPair::from_seed_unchecked(&[1u8; 32]).unwrap();
694 let log_public_key: [u8; 32] = log_keypair.public_key().as_ref().try_into().unwrap();
695
696 let actor_keypair = Ed25519KeyPair::from_seed_unchecked(&[2u8; 32]).unwrap();
697 let actor_public_key: [u8; 32] = actor_keypair.public_key().as_ref().try_into().unwrap();
698 let actor_did = CanonicalDid::from_public_key_did_key(
699 &actor_public_key,
700 auths_crypto::CurveType::Ed25519,
701 )
702 .to_string();
703
704 let trust_root = TrustRoot {
705 log_public_key: Ed25519PublicKey::from_bytes(log_public_key),
706 log_origin: LogOrigin::new("test.dev/log").unwrap(),
707 witnesses: vec![],
708 signature_algorithm: Default::default(),
709 ecdsa_log_public_key_der: None,
710 independence_policy:
711 auths_keri::witness::independence::IndependencePolicy::unconstrained(),
712 };
713
714 TestFixture {
715 log_keypair,
716 log_public_key,
717 actor_keypair,
718 actor_public_key,
719 actor_did,
720 trust_root,
721 }
722 }
723
724 fn make_entry(fixture: &TestFixture) -> Entry {
725 let content = EntryContent {
726 entry_type: EntryType::DeviceBind,
727 body: EntryBody::DeviceBind {
728 device_did: CanonicalDid::new_unchecked(&fixture.actor_did),
729 public_key: Ed25519PublicKey::from_bytes(fixture.actor_public_key),
730 },
731 actor_did: CanonicalDid::new_unchecked(&fixture.actor_did),
732 };
733 let canonical = content.canonicalize().unwrap();
734 let sig_bytes = fixture.actor_keypair.sign(&canonical);
735 let actor_sig = Ed25519Signature::try_from_slice(sig_bytes.as_ref()).unwrap();
736
737 Entry {
738 sequence: 0,
739 timestamp: fixed_ts(),
740 content,
741 actor_sig,
742 }
743 }
744
745 fn make_signed_checkpoint(
746 entry: &Entry,
747 fixture: &TestFixture,
748 ) -> (SignedCheckpoint, InclusionProof) {
749 let leaf_data = entry.leaf_data().unwrap();
750 let leaf_hash = hash_leaf(&leaf_data);
751 let root = compute_root(&[leaf_hash]);
752
753 let checkpoint = Checkpoint {
754 origin: LogOrigin::new("test.dev/log").unwrap(),
755 size: 1,
756 root,
757 timestamp: fixed_ts(),
758 };
759
760 let note_body = checkpoint.to_note_body();
761 let log_sig_bytes = fixture.log_keypair.sign(note_body.as_bytes());
762 let log_signature = Ed25519Signature::try_from_slice(log_sig_bytes.as_ref()).unwrap();
763
764 let signed = SignedCheckpoint {
765 checkpoint,
766 log_signature,
767 log_public_key: Ed25519PublicKey::from_bytes(fixture.log_public_key),
768 witnesses: vec![],
769 ecdsa_checkpoint_signature: None,
770 ecdsa_checkpoint_key: None,
771 };
772
773 let proof = InclusionProof {
774 index: 0,
775 size: 1,
776 root,
777 hashes: vec![],
778 };
779
780 (signed, proof)
781 }
782
783 fn make_valid_bundle(fixture: &TestFixture) -> OfflineBundle {
784 let entry = make_entry(fixture);
785 let (signed_checkpoint, inclusion_proof) = make_signed_checkpoint(&entry, fixture);
786
787 OfflineBundle {
788 entry,
789 inclusion_proof,
790 signed_checkpoint,
791 delegation_chain: vec![],
792 }
793 }
794
795 #[test]
796 fn valid_bundle_all_verified() {
797 let fixture = setup();
798 let bundle = make_valid_bundle(&fixture);
799 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
800
801 assert_eq!(report.signature, SignatureStatus::Verified);
802 assert_eq!(report.inclusion, InclusionStatus::Verified);
803 assert_eq!(report.checkpoint, CheckpointStatus::Verified);
804 assert_eq!(report.witnesses, WitnessStatus::NotProvided);
805 assert!(report.is_valid());
806 assert!(report.warnings.is_empty());
807 }
808
809 #[test]
810 fn bad_signature_fails() {
811 let fixture = setup();
812 let mut bundle = make_valid_bundle(&fixture);
813 bundle.entry.actor_sig = Ed25519Signature::from_bytes([0xaa; 64]);
814
815 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
816
817 assert!(matches!(report.signature, SignatureStatus::Failed { .. }));
818 assert!(!report.is_valid());
819 }
820
821 #[test]
822 fn bad_inclusion_proof_fails() {
823 let fixture = setup();
824 let mut bundle = make_valid_bundle(&fixture);
825 bundle
826 .inclusion_proof
827 .hashes
828 .push(crate::types::MerkleHash::from_bytes([0xff; 32]));
829
830 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
831
832 assert!(matches!(report.inclusion, InclusionStatus::Failed { .. }));
833 }
834
835 #[test]
836 fn stale_checkpoint_produces_warning() {
837 let fixture = setup();
838 let mut bundle = make_valid_bundle(&fixture);
839
840 let old_ts = chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z")
841 .unwrap()
842 .with_timezone(&Utc);
843 bundle.signed_checkpoint.checkpoint.timestamp = old_ts;
844
845 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
846
847 assert!(!report.warnings.is_empty());
848 assert!(report.warnings[0].contains("older than 90 days"));
849 }
850
851 #[test]
852 fn witness_quorum_met() {
853 let w1_keypair = Ed25519KeyPair::from_seed_unchecked(&[10u8; 32]).unwrap();
854 let w1_pk: [u8; 32] = w1_keypair.public_key().as_ref().try_into().unwrap();
855 let w2_keypair = Ed25519KeyPair::from_seed_unchecked(&[11u8; 32]).unwrap();
856 let w2_pk: [u8; 32] = w2_keypair.public_key().as_ref().try_into().unwrap();
857
858 let fixture = setup();
859 let bundle = make_valid_bundle(&fixture);
860
861 let note_body = bundle.signed_checkpoint.checkpoint.to_note_body();
862 let w1_sig = w1_keypair.sign(note_body.as_bytes());
863 let w2_sig = w2_keypair.sign(note_body.as_bytes());
864
865 let mut bundle = bundle;
866 bundle.signed_checkpoint.witnesses = vec![
867 WitnessCosignature {
868 witness_name: "w1".into(),
869 witness_public_key: Ed25519PublicKey::from_bytes(w1_pk),
870 signature: Ed25519Signature::try_from_slice(w1_sig.as_ref()).unwrap(),
871 timestamp: fixed_ts(),
872 },
873 WitnessCosignature {
874 witness_name: "w2".into(),
875 witness_public_key: Ed25519PublicKey::from_bytes(w2_pk),
876 signature: Ed25519Signature::try_from_slice(w2_sig.as_ref()).unwrap(),
877 timestamp: fixed_ts(),
878 },
879 ];
880
881 let trust_root = TrustRoot {
882 log_public_key: Ed25519PublicKey::from_bytes(fixture.log_public_key),
883 log_origin: LogOrigin::new("test.dev/log").unwrap(),
884 witnesses: vec![
885 TrustRootWitness {
886 witness_did: CanonicalDid::from_public_key_did_key(
887 &w1_pk,
888 auths_crypto::CurveType::Ed25519,
889 ),
890 name: "w1".into(),
891 public_key: Ed25519PublicKey::from_bytes(w1_pk),
892 operator_info: None,
893 },
894 TrustRootWitness {
895 witness_did: CanonicalDid::from_public_key_did_key(
896 &w2_pk,
897 auths_crypto::CurveType::Ed25519,
898 ),
899 name: "w2".into(),
900 public_key: Ed25519PublicKey::from_bytes(w2_pk),
901 operator_info: None,
902 },
903 ],
904 signature_algorithm: Default::default(),
905 ecdsa_log_public_key_der: None,
906 independence_policy:
907 auths_keri::witness::independence::IndependencePolicy::unconstrained(),
908 };
909
910 let report = verify_bundle(&bundle, &trust_root, fixed_now());
911 assert!(matches!(
912 report.witnesses,
913 WitnessStatus::Quorum {
914 verified: 2,
915 required: 2,
916 }
917 ));
918 }
919
920 fn witness_status_for_orgs(
923 org1: &str,
924 org2: &str,
925 min_org: usize,
926 min_jur: usize,
927 min_infra: usize,
928 ) -> WitnessStatus {
929 use auths_keri::witness::independence::{
930 IndependencePolicy, Infrastructure, Jurisdiction, OperatorId, Organization,
931 WitnessOperatorInfo,
932 };
933
934 let w1_keypair = Ed25519KeyPair::from_seed_unchecked(&[10u8; 32]).unwrap();
935 let w1_pk: [u8; 32] = w1_keypair.public_key().as_ref().try_into().unwrap();
936 let w2_keypair = Ed25519KeyPair::from_seed_unchecked(&[11u8; 32]).unwrap();
937 let w2_pk: [u8; 32] = w2_keypair.public_key().as_ref().try_into().unwrap();
938
939 let fixture = setup();
940 let mut bundle = make_valid_bundle(&fixture);
941 let note_body = bundle.signed_checkpoint.checkpoint.to_note_body();
942 let w1_sig = w1_keypair.sign(note_body.as_bytes());
943 let w2_sig = w2_keypair.sign(note_body.as_bytes());
944 bundle.signed_checkpoint.witnesses = vec![
945 WitnessCosignature {
946 witness_name: "w1".into(),
947 witness_public_key: Ed25519PublicKey::from_bytes(w1_pk),
948 signature: Ed25519Signature::try_from_slice(w1_sig.as_ref()).unwrap(),
949 timestamp: fixed_ts(),
950 },
951 WitnessCosignature {
952 witness_name: "w2".into(),
953 witness_public_key: Ed25519PublicKey::from_bytes(w2_pk),
954 signature: Ed25519Signature::try_from_slice(w2_sig.as_ref()).unwrap(),
955 timestamp: fixed_ts(),
956 },
957 ];
958
959 let info = |op: &str, org: &str, jur: &str, infra: &str| WitnessOperatorInfo {
960 operator: OperatorId::new(op).unwrap(),
961 organization: Organization::new(org).unwrap(),
962 jurisdiction: Jurisdiction::new(jur).unwrap(),
963 infrastructure: Infrastructure::new(infra).unwrap(),
964 };
965
966 let trust_root = TrustRoot {
967 log_public_key: Ed25519PublicKey::from_bytes(fixture.log_public_key),
968 log_origin: LogOrigin::new("test.dev/log").unwrap(),
969 witnesses: vec![
970 TrustRootWitness {
971 witness_did: CanonicalDid::from_public_key_did_key(
972 &w1_pk,
973 auths_crypto::CurveType::Ed25519,
974 ),
975 name: "w1".into(),
976 public_key: Ed25519PublicKey::from_bytes(w1_pk),
977 operator_info: Some(info("w1", org1, "US", "aws/us-east-1")),
978 },
979 TrustRootWitness {
980 witness_did: CanonicalDid::from_public_key_did_key(
981 &w2_pk,
982 auths_crypto::CurveType::Ed25519,
983 ),
984 name: "w2".into(),
985 public_key: Ed25519PublicKey::from_bytes(w2_pk),
986 operator_info: Some(info("w2", org2, "DE", "gcp/eu-west-1")),
987 },
988 ],
989 signature_algorithm: Default::default(),
990 ecdsa_log_public_key_der: None,
991 independence_policy: IndependencePolicy {
992 min_organizations: min_org,
993 min_jurisdictions: min_jur,
994 min_infra_zones: min_infra,
995 },
996 };
997
998 verify_bundle(&bundle, &trust_root, fixed_now()).witnesses
999 }
1000
1001 #[test]
1002 fn witness_quorum_diverse_passes() {
1003 let status = witness_status_for_orgs("org-a", "org-b", 2, 2, 2);
1005 assert!(
1006 matches!(status, WitnessStatus::Quorum { verified: 2, .. }),
1007 "got {status:?}"
1008 );
1009 }
1010
1011 #[test]
1012 fn witness_quorum_same_org_not_independent() {
1013 let status = witness_status_for_orgs("acme", "acme", 2, 1, 1);
1015 assert!(
1016 matches!(status, WitnessStatus::NotIndependent { .. }),
1017 "got {status:?}"
1018 );
1019 }
1020
1021 #[test]
1022 fn empty_delegation_yields_no_delegation_data() {
1023 let fixture = setup();
1024 let bundle = make_valid_bundle(&fixture);
1025 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
1026 assert_eq!(report.delegation, DelegationStatus::NoDelegationData);
1027 }
1028
1029 #[test]
1030 fn delegation_chain_wrong_length_is_broken() {
1031 let fixture = setup();
1032 let mut bundle = make_valid_bundle(&fixture);
1033
1034 let entry = make_entry(&fixture);
1035 let root = bundle.signed_checkpoint.checkpoint.root;
1036
1037 bundle.delegation_chain = vec![DelegationChainLink {
1038 link_type: EntryType::DeviceBind,
1039 entry,
1040 inclusion_proof: InclusionProof {
1041 index: 0,
1042 size: 1,
1043 root,
1044 hashes: vec![],
1045 },
1046 }];
1047
1048 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
1049 assert!(matches!(
1050 report.delegation,
1051 DelegationStatus::ChainBroken { .. }
1052 ));
1053 }
1054
1055 #[test]
1056 fn checkpoint_origin_mismatch_fails() {
1057 let fixture = setup();
1058 let mut bundle = make_valid_bundle(&fixture);
1059 bundle.signed_checkpoint.checkpoint.origin = LogOrigin::new("other.dev/log").unwrap();
1060
1061 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
1062 assert_eq!(report.checkpoint, CheckpointStatus::InvalidSignature);
1063 }
1064
1065 fn make_signed_entry(
1068 fixture: &TestFixture,
1069 sequence: u128,
1070 entry_type: EntryType,
1071 body: EntryBody,
1072 actor_did: &str,
1073 ) -> Entry {
1074 let content = EntryContent {
1075 entry_type,
1076 body,
1077 actor_did: CanonicalDid::new_unchecked(actor_did),
1078 };
1079 let canonical = content.canonicalize().unwrap();
1080 let sig_bytes = fixture.actor_keypair.sign(&canonical);
1081 let actor_sig = Ed25519Signature::try_from_slice(sig_bytes.as_ref()).unwrap();
1082 Entry {
1083 sequence,
1084 timestamp: fixed_ts(),
1085 content,
1086 actor_sig,
1087 }
1088 }
1089
1090 fn make_delegation_chain(
1096 fixture: &TestFixture,
1097 device_actor_did: &str,
1098 member_did: &str,
1099 ) -> (Vec<DelegationChainLink>, crate::types::MerkleHash) {
1100 let device_bind = make_signed_entry(
1101 fixture,
1102 0,
1103 EntryType::DeviceBind,
1104 EntryBody::DeviceBind {
1105 device_did: CanonicalDid::new_unchecked(&fixture.actor_did),
1106 public_key: Ed25519PublicKey::from_bytes(fixture.actor_public_key),
1107 },
1108 device_actor_did,
1109 );
1110 let org_add_member = make_signed_entry(
1111 fixture,
1112 1,
1113 EntryType::OrgAddMember,
1114 EntryBody::OrgAddMember {
1115 member_did: IdentityDID::parse(member_did).unwrap(),
1116 role: auths_verifier::Role::Admin,
1117 capabilities: vec![Capability::sign_commit()],
1118 delegated_by: IdentityDID::parse("did:keri:EOrg123").unwrap(),
1119 },
1120 "did:keri:EOrg123",
1121 );
1122
1123 let leaves = [
1124 hash_leaf(&device_bind.leaf_data().unwrap()),
1125 hash_leaf(&org_add_member.leaf_data().unwrap()),
1126 ];
1127 let root = compute_root(&leaves);
1128
1129 let link = |entry: Entry, index: u64| DelegationChainLink {
1130 link_type: entry.content.entry_type.clone(),
1131 inclusion_proof: InclusionProof {
1132 index,
1133 size: 2,
1134 root,
1135 hashes: crate::merkle::prove_inclusion(&leaves, index).unwrap(),
1136 },
1137 entry,
1138 };
1139
1140 (vec![link(device_bind, 0), link(org_add_member, 1)], root)
1141 }
1142
1143 #[test]
1144 fn delegation_chain_with_valid_keri_actor_verifies() {
1145 let fixture = setup();
1146 let mut bundle = make_valid_bundle(&fixture);
1147 let (chain, root) =
1148 make_delegation_chain(&fixture, "did:keri:EMember456", "did:keri:EMember456");
1149 bundle.signed_checkpoint.checkpoint.root = root;
1150 bundle.delegation_chain = chain;
1151
1152 let delegation = verify_delegation_chain(&bundle);
1153 assert!(matches!(delegation, DelegationStatus::ChainVerified { .. }));
1154 }
1155
1156 #[test]
1157 fn delegation_chain_with_non_keri_actor_is_broken() {
1158 let fixture = setup();
1159 let mut bundle = make_valid_bundle(&fixture);
1160 let (chain, root) = make_delegation_chain(
1161 &fixture,
1162 "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
1163 "did:keri:EMember456",
1164 );
1165 bundle.signed_checkpoint.checkpoint.root = root;
1166 bundle.delegation_chain = chain;
1167
1168 let delegation = verify_delegation_chain(&bundle);
1169 let DelegationStatus::ChainBroken { reason } = delegation else {
1170 panic!("expected ChainBroken for a non-keri actor DID, got {delegation:?}");
1171 };
1172 assert!(
1173 reason.contains("not a valid identity DID"),
1174 "unexpected reason: {reason}"
1175 );
1176 }
1177}