Skip to main content

auths_transparency/
verify.rs

1//! Offline bundle verification logic.
2//!
3//! Provides [`verify_bundle`] — a synchronous, I/O-free function that verifies
4//! an [`OfflineBundle`] against a [`TrustRoot`].
5
6use 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
21/// Verifies an offline transparency bundle against a trust root.
22///
23/// Each verification dimension (signature, inclusion, checkpoint, witnesses,
24/// namespace, delegation) is evaluated independently so callers can make
25/// nuanced trust decisions.
26///
27/// Args:
28/// * `bundle` — The offline bundle to verify.
29/// * `trust_root` — Trusted log public key and witness set.
30/// * `now` — Current wall-clock time (injected, never read from system clock).
31///
32/// Usage:
33/// ```ignore
34/// let report = verify_bundle(&bundle, &trust_root, now);
35/// if report.is_valid() {
36///     // bundle is trustworthy
37/// }
38/// ```
39pub 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            // One source of truth for Ed25519 checkpoint verification: the
168            // pinned-key check in the shared verifier core (also requires the
169            // checkpoint's embedded key to BE the pinned key, so a bundle
170            // can't carry a self-chosen operator identity).
171            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            // For ECDSA P-256, the C2SP signed-note `log_signature` field
178            // is Ed25519-pinned by spec (64-byte fixed). We carry the
179            // ECDSA DER signature + key in sibling
180            // `ecdsa_checkpoint_signature` / `ecdsa_checkpoint_key`
181            // fields that the Rekor adapter populates. Spec link:
182            // https://c2sp.org/signed-note.
183            //
184            // Missing fields are a STRUCTURAL error — operators
185            // investigating log misbehavior need to tell "the signature
186            // didn't verify" apart from "the signature doesn't exist in
187            // the bundle at all". Falling through to InvalidSignature
188            // conflates these and hides the real cause.
189            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            // Bundle-carried key must match the pinned key from the
196            // trust root byte-for-byte — otherwise the verifier is
197            // just checking "did this key sign this message" which
198            // is trivially forgeable.
199            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            // ring's ECDSA verifier consumes the raw uncompressed SEC1 point,
205            // not the DER SPKI we carry — convert before verifying.
206            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
218/// Verify only the checkpoint-signature dimension of a [`SignedCheckpoint`]
219/// against a [`TrustRoot`], dispatching on the trust root's signature algorithm
220/// (Ed25519 or ECDSA-P256). Exposed for the submit/verify flow, which holds a
221/// checkpoint without assembling a full [`OfflineBundle`].
222///
223/// Args:
224/// * `signed` — The signed checkpoint to verify.
225/// * `trust_root` — The pinned trust root for the log that produced it.
226///
227/// Usage:
228/// ```ignore
229/// let status = verify_checkpoint_signature(&signed, &trust_root);
230/// ```
231pub fn verify_checkpoint_signature(
232    signed: &SignedCheckpoint,
233    trust_root: &TrustRoot,
234) -> CheckpointStatus {
235    verify_checkpoint(signed, trust_root)
236}
237
238/// Verify only the witness-cosignature dimension of a [`SignedCheckpoint`] against a
239/// [`TrustRoot`], returning the quorum + independence verdict.
240///
241/// Each cosignature is checked against a trusted witness (matched in constant time)
242/// and the present cosigning quorum is evaluated for independence under the trust
243/// root's policy. Exposed for the monitor, which holds a checkpoint without
244/// assembling a full [`OfflineBundle`] and must not re-implement Ed25519 cosignature
245/// verification itself.
246///
247/// Args:
248/// * `signed` — The signed checkpoint whose witness cosignatures to verify.
249/// * `trust_root` — The pinned trust root (witness roster + independence policy).
250///
251/// Usage:
252/// ```ignore
253/// let status = verify_witness_cosignatures(&signed, &trust_root);
254/// ```
255pub 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    // Independence is evaluated over the ACTUAL cosigning quorum — the witnesses
271    // whose cosignatures verify here — not the configured roster. A diverse roster
272    // with a correlated quorum must still fail.
273    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                // A verified cosigner without pinned attributes cannot contribute
292                // to diversity (fail closed under a real policy).
293                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    // Count met — require the present cosigners to be independent. Under the
305    // unconstrained default policy this passes trivially (0 ≥ 0), preserving
306    // legacy behavior; under a pinned commons policy it enforces diversity.
307    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)] // INVARIANT: 90 days always fits in Duration
324    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            // NamespaceDelegate can repeat (multi-hop delegation)
368        } 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        // 2-link chain: [DeviceBind, NamespaceClaim] — direct ownership, no org
554        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    /// Build a 2-witness checkpoint cosigned by both, with the given operator
921    /// organizations and diversity thresholds, and return the witness verdict.
922    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        // 2 orgs / 2 jurisdictions / 2 infra zones meets a 2/2/2 policy.
1004        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        // Count is met (2 ≥ 2) but both cosigners share an org → NotIndependent.
1014        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    /// Builds an entry with the given type, body, and actor DID, signed by the
1066    /// fixture actor key and stamped at the given sequence number.
1067    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    /// Assembles a two-link `[DeviceBind, OrgAddMember]` delegation chain over a
1091    /// two-leaf Merkle tree, returning links with valid inclusion proofs plus the
1092    /// shared checkpoint root. The `device_actor_did` flows into the DeviceBind
1093    /// link's `actor_did`, which the verifier parses as an identity DID;
1094    /// `member_did` is the identity the OrgAddMember link delegates to.
1095    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}