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 let peer_key = UnparsedPublicKey::new(&ED25519, trust_root.log_public_key.as_bytes());
168 match peer_key.verify(note_body.as_bytes(), signed.log_signature.as_bytes()) {
169 Ok(()) => CheckpointStatus::Verified,
170 Err(_) => CheckpointStatus::InvalidSignature,
171 }
172 }
173 auths_verifier::SignatureAlgorithm::EcdsaP256 => {
174 let Some(ecdsa_sig) = &signed.ecdsa_checkpoint_signature else {
187 return CheckpointStatus::MissingEcdsaSignature;
188 };
189 let Some(ecdsa_pk) = &signed.ecdsa_checkpoint_key else {
190 return CheckpointStatus::MissingEcdsaKey;
191 };
192 if let Some(pinned) = trust_root.ecdsa_log_public_key_der.as_deref()
197 && pinned != ecdsa_pk.as_der()
198 {
199 return CheckpointStatus::InvalidSignature;
200 }
201 let Some(point) = ecdsa_pk.as_sec1_uncompressed() else {
204 return CheckpointStatus::InvalidSignature;
205 };
206 let peer_key = UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, point);
207 match peer_key.verify(note_body.as_bytes(), ecdsa_sig.as_der()) {
208 Ok(()) => CheckpointStatus::Verified,
209 Err(_) => CheckpointStatus::InvalidSignature,
210 }
211 }
212 }
213}
214
215pub fn verify_checkpoint_signature(
229 signed: &SignedCheckpoint,
230 trust_root: &TrustRoot,
231) -> CheckpointStatus {
232 verify_checkpoint(signed, trust_root)
233}
234
235pub fn verify_witness_cosignatures(
253 signed: &SignedCheckpoint,
254 trust_root: &TrustRoot,
255) -> WitnessStatus {
256 verify_witnesses(signed, trust_root)
257}
258
259fn verify_witnesses(signed: &SignedCheckpoint, trust_root: &TrustRoot) -> WitnessStatus {
260 if trust_root.witnesses.is_empty() {
261 return WitnessStatus::NotProvided;
262 }
263
264 let note_body = signed.checkpoint.to_note_body();
265 let required = trust_root.witnesses.len() / 2 + 1;
266 let mut verified = 0usize;
267 let mut cosigner_attrs = Vec::new();
271
272 for cosig in &signed.witnesses {
273 let trusted = trust_root.witnesses.iter().find(|w| {
274 use subtle::ConstantTimeEq;
275 w.public_key
276 .as_bytes()
277 .ct_eq(cosig.witness_public_key.as_bytes())
278 .into()
279 });
280
281 if let Some(witness) = trusted {
282 let peer_key = UnparsedPublicKey::new(&ED25519, cosig.witness_public_key.as_bytes());
283 if peer_key
284 .verify(note_body.as_bytes(), cosig.signature.as_bytes())
285 .is_ok()
286 {
287 verified += 1;
288 if let Some(attrs) = witness.operator_attributes() {
291 cosigner_attrs.push(attrs);
292 }
293 }
294 }
295 }
296
297 if verified < required {
298 return WitnessStatus::Insufficient { verified, required };
299 }
300
301 let independence = auths_keri::witness::independence::spans_distinct(
305 &cosigner_attrs,
306 &trust_root.independence_policy,
307 );
308 if independence.independent {
309 WitnessStatus::Quorum { verified, required }
310 } else {
311 WitnessStatus::NotIndependent {
312 verified,
313 required,
314 shortfalls: independence.shortfalls,
315 }
316 }
317}
318
319fn check_staleness(signed: &SignedCheckpoint, now: DateTime<Utc>, warnings: &mut Vec<String>) {
320 #[allow(clippy::expect_used)] let stale_threshold =
322 Duration::try_days(STALE_BUNDLE_DAYS).expect("STALE_BUNDLE_DAYS is a small constant");
323 if now - signed.checkpoint.timestamp > stale_threshold {
324 warnings.push(format!(
325 "bundle checkpoint is older than {} days",
326 STALE_BUNDLE_DAYS
327 ));
328 }
329}
330
331fn validate_chain_link_order(
332 chain: &[crate::bundle::DelegationChainLink],
333) -> Option<DelegationStatus> {
334 if chain[0].link_type != EntryType::DeviceBind {
335 return Some(DelegationStatus::ChainBroken {
336 reason: format!(
337 "link[0] expected type {:?}, got {:?}",
338 EntryType::DeviceBind,
339 chain[0].link_type
340 ),
341 });
342 }
343
344 let allowed_order = [
345 EntryType::OrgAddMember,
346 EntryType::NamespaceClaim,
347 EntryType::NamespaceDelegate,
348 ];
349
350 let mut order_idx = 0;
351 for (i, link) in chain.iter().enumerate().skip(1) {
352 while order_idx < allowed_order.len() && link.link_type != allowed_order[order_idx] {
353 order_idx += 1;
354 }
355 if order_idx >= allowed_order.len() {
356 return Some(DelegationStatus::ChainBroken {
357 reason: format!(
358 "link[{i}] unexpected type {:?} at this position",
359 link.link_type
360 ),
361 });
362 }
363 if link.link_type == EntryType::NamespaceDelegate {
364 } else {
366 order_idx += 1;
367 }
368 }
369
370 let has_namespace_claim = chain
371 .iter()
372 .any(|l| l.link_type == EntryType::NamespaceClaim);
373 let has_namespace_delegate = chain
374 .iter()
375 .any(|l| l.link_type == EntryType::NamespaceDelegate);
376 if has_namespace_delegate && !has_namespace_claim {
377 return Some(DelegationStatus::ChainBroken {
378 reason: "NamespaceDelegate requires a preceding NamespaceClaim".into(),
379 });
380 }
381
382 if !has_namespace_claim
383 && !chain
384 .iter()
385 .skip(1)
386 .any(|l| l.link_type == EntryType::OrgAddMember)
387 {
388 return Some(DelegationStatus::ChainBroken {
389 reason: "chain must contain at least OrgAddMember or NamespaceClaim after DeviceBind"
390 .into(),
391 });
392 }
393
394 None
395}
396
397fn verify_link_inclusion_proofs(
398 chain: &[crate::bundle::DelegationChainLink],
399 checkpoint_root: &crate::types::MerkleHash,
400) -> Option<DelegationStatus> {
401 let mut sequences: Vec<u128> = chain.iter().map(|l| l.entry.sequence).collect();
402 sequences.sort_unstable();
403 sequences.dedup();
404 if sequences.len() != chain.len() {
405 return Some(DelegationStatus::ChainBroken {
406 reason: "duplicate sequence numbers in delegation chain".into(),
407 });
408 }
409
410 for (i, link) in chain.iter().enumerate() {
411 let leaf_data = match link.entry.leaf_data() {
412 Ok(d) => d,
413 Err(e) => {
414 return Some(DelegationStatus::ChainBroken {
415 reason: format!("link[{i}] leaf data failed: {e}"),
416 });
417 }
418 };
419 let leaf_hash = hash_leaf(&leaf_data);
420 let proof = &link.inclusion_proof;
421 if let Err(e) = crate::merkle::verify_inclusion(
422 &leaf_hash,
423 proof.index,
424 proof.size,
425 &proof.hashes,
426 &proof.root,
427 ) {
428 return Some(DelegationStatus::ChainBroken {
429 reason: format!("link[{i}] inclusion proof failed: {e}"),
430 });
431 }
432 if &proof.root != checkpoint_root {
433 return Some(DelegationStatus::ChainBroken {
434 reason: format!("link[{i}] proof root does not match checkpoint"),
435 });
436 }
437 }
438
439 None
440}
441
442fn extract_namespace_from_entry(body: &EntryBody) -> Option<(&str, &str)> {
443 match body {
444 EntryBody::NamespaceClaim {
445 ecosystem,
446 package_name,
447 ..
448 }
449 | EntryBody::NamespaceDelegate {
450 ecosystem,
451 package_name,
452 ..
453 }
454 | EntryBody::NamespaceTransfer {
455 ecosystem,
456 package_name,
457 ..
458 } => Some((ecosystem.as_str(), package_name.as_str())),
459 _ => None,
460 }
461}
462
463fn verify_delegation_chain(bundle: &OfflineBundle) -> DelegationStatus {
464 if bundle.delegation_chain.is_empty() {
465 return DelegationStatus::NoDelegationData;
466 }
467
468 let chain = &bundle.delegation_chain;
469
470 if chain.len() < 2 {
471 return DelegationStatus::ChainBroken {
472 reason: format!("chain must have at least 2 links, got {}", chain.len()),
473 };
474 }
475
476 if let Some(broken) = validate_chain_link_order(chain) {
477 return broken;
478 }
479
480 let checkpoint_root = &bundle.signed_checkpoint.checkpoint.root;
481 if let Some(broken) = verify_link_inclusion_proofs(chain, checkpoint_root) {
482 return broken;
483 }
484
485 let device_did = match &chain[0].entry.content.body {
486 EntryBody::DeviceBind { device_did, .. } => device_did.clone(),
487 _ => {
488 return DelegationStatus::ChainBroken {
489 reason: "link[0] body is not DeviceBind".into(),
490 };
491 }
492 };
493
494 #[allow(clippy::disallowed_methods)]
495 let identity_did = IdentityDID::new_unchecked(chain[0].entry.content.actor_did.as_str());
497
498 let org_add_member = chain
499 .iter()
500 .enumerate()
501 .find(|(_, l)| l.link_type == EntryType::OrgAddMember);
502
503 let namespace_claim = chain
504 .iter()
505 .enumerate()
506 .find(|(_, l)| l.link_type == EntryType::NamespaceClaim);
507
508 let (member_did, member_role, org_did) = if let Some((idx, link)) = org_add_member {
509 match &link.entry.content.body {
510 EntryBody::OrgAddMember {
511 member_did,
512 role,
513 capabilities,
514 ..
515 } => {
516 let bundle_is_namespace_op =
517 extract_namespace_from_entry(&bundle.entry.content.body).is_some();
518 if bundle_is_namespace_op && !capabilities.contains(&Capability::sign_release()) {
519 return DelegationStatus::ChainBroken {
520 reason: format!(
521 "link[{idx}] OrgAddMember lacks sign_release capability required for namespace operations"
522 ),
523 };
524 }
525
526 #[allow(clippy::disallowed_methods)]
527 let org = IdentityDID::new_unchecked(link.entry.content.actor_did.as_str());
529 (member_did.clone(), *role, org)
530 }
531 _ => {
532 return DelegationStatus::ChainBroken {
533 reason: format!("link[{idx}] body is not OrgAddMember"),
534 };
535 }
536 }
537 } else {
538 #[allow(clippy::disallowed_methods)]
540 let owner_did = IdentityDID::new_unchecked(chain[0].entry.content.actor_did.as_str());
542 (owner_did.clone(), auths_verifier::Role::Admin, owner_did)
543 };
544
545 if member_did.as_str() != identity_did.as_str() {
546 return DelegationStatus::ChainBroken {
547 reason: format!(
548 "DID connectivity broken: OrgAddMember member_did ({}) != DeviceBind actor_did ({})",
549 member_did, identity_did
550 ),
551 };
552 }
553
554 if let Some((ns_idx, ns_link)) = namespace_claim {
555 if let EntryBody::NamespaceClaim {
556 ecosystem: claim_ecosystem,
557 package_name: claim_package,
558 ..
559 } = &ns_link.entry.content.body
560 {
561 if org_add_member.is_some() {
562 #[allow(clippy::disallowed_methods)]
563 let claim_actor =
565 IdentityDID::new_unchecked(ns_link.entry.content.actor_did.as_str());
566 if claim_actor.as_str() != org_did.as_str() {
567 return DelegationStatus::ChainBroken {
568 reason: format!(
569 "link[{ns_idx}] NamespaceClaim actor_did ({}) != org_did ({})",
570 claim_actor, org_did
571 ),
572 };
573 }
574 }
575
576 if let Some((bundle_ecosystem, bundle_package)) =
577 extract_namespace_from_entry(&bundle.entry.content.body)
578 && (claim_ecosystem != bundle_ecosystem || claim_package != bundle_package)
579 {
580 return DelegationStatus::ChainBroken {
581 reason: format!(
582 "link[{ns_idx}] NamespaceClaim namespace ({}/{}) does not match bundle entry ({}/{})",
583 claim_ecosystem, claim_package, bundle_ecosystem, bundle_package
584 ),
585 };
586 }
587 } else {
588 return DelegationStatus::ChainBroken {
589 reason: format!("link[{ns_idx}] body is not NamespaceClaim"),
590 };
591 }
592 }
593
594 DelegationStatus::ChainVerified {
595 org_did,
596 member_did,
597 member_role,
598 device_did,
599 }
600}
601
602fn derive_namespace_status(
603 delegation: &DelegationStatus,
604 bundle: &OfflineBundle,
605) -> NamespaceStatus {
606 match delegation {
607 DelegationStatus::ChainVerified { .. } => {
608 let has_namespace_delegate = bundle
609 .delegation_chain
610 .iter()
611 .any(|link| link.link_type == EntryType::NamespaceDelegate);
612 let has_namespace_claim = bundle
613 .delegation_chain
614 .iter()
615 .any(|link| link.link_type == EntryType::NamespaceClaim);
616 if has_namespace_delegate || has_namespace_claim {
617 NamespaceStatus::Authorized
618 } else {
619 NamespaceStatus::Owned
620 }
621 }
622 DelegationStatus::Direct => NamespaceStatus::Owned,
623 DelegationStatus::NoDelegationData => NamespaceStatus::Owned,
624 DelegationStatus::ChainBroken { .. } => NamespaceStatus::Unauthorized,
625 }
626}
627
628#[cfg(test)]
629#[allow(clippy::disallowed_methods)]
630mod tests {
631 use super::*;
632 use crate::TrustRootWitness;
633 use crate::bundle::DelegationChainLink;
634 use crate::checkpoint::{Checkpoint, WitnessCosignature};
635 use crate::entry::{Entry, EntryContent};
636 use crate::merkle::compute_root;
637 use crate::proof::InclusionProof;
638 use crate::types::LogOrigin;
639 use auths_verifier::{CanonicalDid, Ed25519PublicKey, Ed25519Signature};
640 use ring::signature::{Ed25519KeyPair, KeyPair};
641
642 fn fixed_now() -> DateTime<Utc> {
643 chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
644 .unwrap()
645 .with_timezone(&Utc)
646 }
647
648 fn fixed_ts() -> DateTime<Utc> {
649 chrono::DateTime::parse_from_rfc3339("2025-06-15T00:00:00Z")
650 .unwrap()
651 .with_timezone(&Utc)
652 }
653
654 struct TestFixture {
655 log_keypair: Ed25519KeyPair,
656 log_public_key: [u8; 32],
657 actor_keypair: Ed25519KeyPair,
658 actor_public_key: [u8; 32],
659 actor_did: String,
660 trust_root: TrustRoot,
661 }
662
663 fn setup() -> TestFixture {
664 let log_keypair = Ed25519KeyPair::from_seed_unchecked(&[1u8; 32]).unwrap();
665 let log_public_key: [u8; 32] = log_keypair.public_key().as_ref().try_into().unwrap();
666
667 let actor_keypair = Ed25519KeyPair::from_seed_unchecked(&[2u8; 32]).unwrap();
668 let actor_public_key: [u8; 32] = actor_keypair.public_key().as_ref().try_into().unwrap();
669 let actor_did = CanonicalDid::from_public_key_did_key(
670 &actor_public_key,
671 auths_crypto::CurveType::Ed25519,
672 )
673 .to_string();
674
675 let trust_root = TrustRoot {
676 log_public_key: Ed25519PublicKey::from_bytes(log_public_key),
677 log_origin: LogOrigin::new("test.dev/log").unwrap(),
678 witnesses: vec![],
679 signature_algorithm: Default::default(),
680 ecdsa_log_public_key_der: None,
681 independence_policy:
682 auths_keri::witness::independence::IndependencePolicy::unconstrained(),
683 };
684
685 TestFixture {
686 log_keypair,
687 log_public_key,
688 actor_keypair,
689 actor_public_key,
690 actor_did,
691 trust_root,
692 }
693 }
694
695 fn make_entry(fixture: &TestFixture) -> Entry {
696 let content = EntryContent {
697 entry_type: EntryType::DeviceBind,
698 body: EntryBody::DeviceBind {
699 device_did: CanonicalDid::new_unchecked(&fixture.actor_did),
700 public_key: Ed25519PublicKey::from_bytes(fixture.actor_public_key),
701 },
702 actor_did: CanonicalDid::new_unchecked(&fixture.actor_did),
703 };
704 let canonical = content.canonicalize().unwrap();
705 let sig_bytes = fixture.actor_keypair.sign(&canonical);
706 let actor_sig = Ed25519Signature::try_from_slice(sig_bytes.as_ref()).unwrap();
707
708 Entry {
709 sequence: 0,
710 timestamp: fixed_ts(),
711 content,
712 actor_sig,
713 }
714 }
715
716 fn make_signed_checkpoint(
717 entry: &Entry,
718 fixture: &TestFixture,
719 ) -> (SignedCheckpoint, InclusionProof) {
720 let leaf_data = entry.leaf_data().unwrap();
721 let leaf_hash = hash_leaf(&leaf_data);
722 let root = compute_root(&[leaf_hash]);
723
724 let checkpoint = Checkpoint {
725 origin: LogOrigin::new("test.dev/log").unwrap(),
726 size: 1,
727 root,
728 timestamp: fixed_ts(),
729 };
730
731 let note_body = checkpoint.to_note_body();
732 let log_sig_bytes = fixture.log_keypair.sign(note_body.as_bytes());
733 let log_signature = Ed25519Signature::try_from_slice(log_sig_bytes.as_ref()).unwrap();
734
735 let signed = SignedCheckpoint {
736 checkpoint,
737 log_signature,
738 log_public_key: Ed25519PublicKey::from_bytes(fixture.log_public_key),
739 witnesses: vec![],
740 ecdsa_checkpoint_signature: None,
741 ecdsa_checkpoint_key: None,
742 };
743
744 let proof = InclusionProof {
745 index: 0,
746 size: 1,
747 root,
748 hashes: vec![],
749 };
750
751 (signed, proof)
752 }
753
754 fn make_valid_bundle(fixture: &TestFixture) -> OfflineBundle {
755 let entry = make_entry(fixture);
756 let (signed_checkpoint, inclusion_proof) = make_signed_checkpoint(&entry, fixture);
757
758 OfflineBundle {
759 entry,
760 inclusion_proof,
761 signed_checkpoint,
762 delegation_chain: vec![],
763 }
764 }
765
766 #[test]
767 fn valid_bundle_all_verified() {
768 let fixture = setup();
769 let bundle = make_valid_bundle(&fixture);
770 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
771
772 assert_eq!(report.signature, SignatureStatus::Verified);
773 assert_eq!(report.inclusion, InclusionStatus::Verified);
774 assert_eq!(report.checkpoint, CheckpointStatus::Verified);
775 assert_eq!(report.witnesses, WitnessStatus::NotProvided);
776 assert!(report.is_valid());
777 assert!(report.warnings.is_empty());
778 }
779
780 #[test]
781 fn bad_signature_fails() {
782 let fixture = setup();
783 let mut bundle = make_valid_bundle(&fixture);
784 bundle.entry.actor_sig = Ed25519Signature::from_bytes([0xaa; 64]);
785
786 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
787
788 assert!(matches!(report.signature, SignatureStatus::Failed { .. }));
789 assert!(!report.is_valid());
790 }
791
792 #[test]
793 fn bad_inclusion_proof_fails() {
794 let fixture = setup();
795 let mut bundle = make_valid_bundle(&fixture);
796 bundle
797 .inclusion_proof
798 .hashes
799 .push(crate::types::MerkleHash::from_bytes([0xff; 32]));
800
801 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
802
803 assert!(matches!(report.inclusion, InclusionStatus::Failed { .. }));
804 }
805
806 #[test]
807 fn stale_checkpoint_produces_warning() {
808 let fixture = setup();
809 let mut bundle = make_valid_bundle(&fixture);
810
811 let old_ts = chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z")
812 .unwrap()
813 .with_timezone(&Utc);
814 bundle.signed_checkpoint.checkpoint.timestamp = old_ts;
815
816 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
817
818 assert!(!report.warnings.is_empty());
819 assert!(report.warnings[0].contains("older than 90 days"));
820 }
821
822 #[test]
823 fn witness_quorum_met() {
824 let w1_keypair = Ed25519KeyPair::from_seed_unchecked(&[10u8; 32]).unwrap();
825 let w1_pk: [u8; 32] = w1_keypair.public_key().as_ref().try_into().unwrap();
826 let w2_keypair = Ed25519KeyPair::from_seed_unchecked(&[11u8; 32]).unwrap();
827 let w2_pk: [u8; 32] = w2_keypair.public_key().as_ref().try_into().unwrap();
828
829 let fixture = setup();
830 let bundle = make_valid_bundle(&fixture);
831
832 let note_body = bundle.signed_checkpoint.checkpoint.to_note_body();
833 let w1_sig = w1_keypair.sign(note_body.as_bytes());
834 let w2_sig = w2_keypair.sign(note_body.as_bytes());
835
836 let mut bundle = bundle;
837 bundle.signed_checkpoint.witnesses = vec![
838 WitnessCosignature {
839 witness_name: "w1".into(),
840 witness_public_key: Ed25519PublicKey::from_bytes(w1_pk),
841 signature: Ed25519Signature::try_from_slice(w1_sig.as_ref()).unwrap(),
842 timestamp: fixed_ts(),
843 },
844 WitnessCosignature {
845 witness_name: "w2".into(),
846 witness_public_key: Ed25519PublicKey::from_bytes(w2_pk),
847 signature: Ed25519Signature::try_from_slice(w2_sig.as_ref()).unwrap(),
848 timestamp: fixed_ts(),
849 },
850 ];
851
852 let trust_root = TrustRoot {
853 log_public_key: Ed25519PublicKey::from_bytes(fixture.log_public_key),
854 log_origin: LogOrigin::new("test.dev/log").unwrap(),
855 witnesses: vec![
856 TrustRootWitness {
857 witness_did: CanonicalDid::from_public_key_did_key(
858 &w1_pk,
859 auths_crypto::CurveType::Ed25519,
860 ),
861 name: "w1".into(),
862 public_key: Ed25519PublicKey::from_bytes(w1_pk),
863 operator_info: None,
864 },
865 TrustRootWitness {
866 witness_did: CanonicalDid::from_public_key_did_key(
867 &w2_pk,
868 auths_crypto::CurveType::Ed25519,
869 ),
870 name: "w2".into(),
871 public_key: Ed25519PublicKey::from_bytes(w2_pk),
872 operator_info: None,
873 },
874 ],
875 signature_algorithm: Default::default(),
876 ecdsa_log_public_key_der: None,
877 independence_policy:
878 auths_keri::witness::independence::IndependencePolicy::unconstrained(),
879 };
880
881 let report = verify_bundle(&bundle, &trust_root, fixed_now());
882 assert!(matches!(
883 report.witnesses,
884 WitnessStatus::Quorum {
885 verified: 2,
886 required: 2,
887 }
888 ));
889 }
890
891 fn witness_status_for_orgs(
894 org1: &str,
895 org2: &str,
896 min_org: usize,
897 min_jur: usize,
898 min_infra: usize,
899 ) -> WitnessStatus {
900 use auths_keri::witness::independence::{
901 IndependencePolicy, Infrastructure, Jurisdiction, OperatorId, Organization,
902 WitnessOperatorInfo,
903 };
904
905 let w1_keypair = Ed25519KeyPair::from_seed_unchecked(&[10u8; 32]).unwrap();
906 let w1_pk: [u8; 32] = w1_keypair.public_key().as_ref().try_into().unwrap();
907 let w2_keypair = Ed25519KeyPair::from_seed_unchecked(&[11u8; 32]).unwrap();
908 let w2_pk: [u8; 32] = w2_keypair.public_key().as_ref().try_into().unwrap();
909
910 let fixture = setup();
911 let mut bundle = make_valid_bundle(&fixture);
912 let note_body = bundle.signed_checkpoint.checkpoint.to_note_body();
913 let w1_sig = w1_keypair.sign(note_body.as_bytes());
914 let w2_sig = w2_keypair.sign(note_body.as_bytes());
915 bundle.signed_checkpoint.witnesses = vec![
916 WitnessCosignature {
917 witness_name: "w1".into(),
918 witness_public_key: Ed25519PublicKey::from_bytes(w1_pk),
919 signature: Ed25519Signature::try_from_slice(w1_sig.as_ref()).unwrap(),
920 timestamp: fixed_ts(),
921 },
922 WitnessCosignature {
923 witness_name: "w2".into(),
924 witness_public_key: Ed25519PublicKey::from_bytes(w2_pk),
925 signature: Ed25519Signature::try_from_slice(w2_sig.as_ref()).unwrap(),
926 timestamp: fixed_ts(),
927 },
928 ];
929
930 let info = |op: &str, org: &str, jur: &str, infra: &str| WitnessOperatorInfo {
931 operator: OperatorId::new(op).unwrap(),
932 organization: Organization::new(org).unwrap(),
933 jurisdiction: Jurisdiction::new(jur).unwrap(),
934 infrastructure: Infrastructure::new(infra).unwrap(),
935 };
936
937 let trust_root = TrustRoot {
938 log_public_key: Ed25519PublicKey::from_bytes(fixture.log_public_key),
939 log_origin: LogOrigin::new("test.dev/log").unwrap(),
940 witnesses: vec![
941 TrustRootWitness {
942 witness_did: CanonicalDid::from_public_key_did_key(
943 &w1_pk,
944 auths_crypto::CurveType::Ed25519,
945 ),
946 name: "w1".into(),
947 public_key: Ed25519PublicKey::from_bytes(w1_pk),
948 operator_info: Some(info("w1", org1, "US", "aws/us-east-1")),
949 },
950 TrustRootWitness {
951 witness_did: CanonicalDid::from_public_key_did_key(
952 &w2_pk,
953 auths_crypto::CurveType::Ed25519,
954 ),
955 name: "w2".into(),
956 public_key: Ed25519PublicKey::from_bytes(w2_pk),
957 operator_info: Some(info("w2", org2, "DE", "gcp/eu-west-1")),
958 },
959 ],
960 signature_algorithm: Default::default(),
961 ecdsa_log_public_key_der: None,
962 independence_policy: IndependencePolicy {
963 min_organizations: min_org,
964 min_jurisdictions: min_jur,
965 min_infra_zones: min_infra,
966 },
967 };
968
969 verify_bundle(&bundle, &trust_root, fixed_now()).witnesses
970 }
971
972 #[test]
973 fn witness_quorum_diverse_passes() {
974 let status = witness_status_for_orgs("org-a", "org-b", 2, 2, 2);
976 assert!(
977 matches!(status, WitnessStatus::Quorum { verified: 2, .. }),
978 "got {status:?}"
979 );
980 }
981
982 #[test]
983 fn witness_quorum_same_org_not_independent() {
984 let status = witness_status_for_orgs("acme", "acme", 2, 1, 1);
986 assert!(
987 matches!(status, WitnessStatus::NotIndependent { .. }),
988 "got {status:?}"
989 );
990 }
991
992 #[test]
993 fn empty_delegation_yields_no_delegation_data() {
994 let fixture = setup();
995 let bundle = make_valid_bundle(&fixture);
996 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
997 assert_eq!(report.delegation, DelegationStatus::NoDelegationData);
998 }
999
1000 #[test]
1001 fn delegation_chain_wrong_length_is_broken() {
1002 let fixture = setup();
1003 let mut bundle = make_valid_bundle(&fixture);
1004
1005 let entry = make_entry(&fixture);
1006 let root = bundle.signed_checkpoint.checkpoint.root;
1007
1008 bundle.delegation_chain = vec![DelegationChainLink {
1009 link_type: EntryType::DeviceBind,
1010 entry,
1011 inclusion_proof: InclusionProof {
1012 index: 0,
1013 size: 1,
1014 root,
1015 hashes: vec![],
1016 },
1017 }];
1018
1019 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
1020 assert!(matches!(
1021 report.delegation,
1022 DelegationStatus::ChainBroken { .. }
1023 ));
1024 }
1025
1026 #[test]
1027 fn checkpoint_origin_mismatch_fails() {
1028 let fixture = setup();
1029 let mut bundle = make_valid_bundle(&fixture);
1030 bundle.signed_checkpoint.checkpoint.origin = LogOrigin::new("other.dev/log").unwrap();
1031
1032 let report = verify_bundle(&bundle, &fixture.trust_root, fixed_now());
1033 assert_eq!(report.checkpoint, CheckpointStatus::InvalidSignature);
1034 }
1035}