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            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            // For ECDSA P-256, the C2SP signed-note `log_signature` field
175            // is Ed25519-pinned by spec (64-byte fixed). We carry the
176            // ECDSA DER signature + key in sibling
177            // `ecdsa_checkpoint_signature` / `ecdsa_checkpoint_key`
178            // fields that the Rekor adapter populates. Spec link:
179            // https://c2sp.org/signed-note.
180            //
181            // Missing fields are a STRUCTURAL error — operators
182            // investigating log misbehavior need to tell "the signature
183            // didn't verify" apart from "the signature doesn't exist in
184            // the bundle at all". Falling through to InvalidSignature
185            // conflates these and hides the real cause.
186            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            // Bundle-carried key must match the pinned key from the
193            // trust root byte-for-byte — otherwise the verifier is
194            // just checking "did this key sign this message" which
195            // is trivially forgeable.
196            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            // ring's ECDSA verifier consumes the raw uncompressed SEC1 point,
202            // not the DER SPKI we carry — convert before verifying.
203            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
215/// Verify only the checkpoint-signature dimension of a [`SignedCheckpoint`]
216/// against a [`TrustRoot`], dispatching on the trust root's signature algorithm
217/// (Ed25519 or ECDSA-P256). Exposed for the submit/verify flow, which holds a
218/// checkpoint without assembling a full [`OfflineBundle`].
219///
220/// Args:
221/// * `signed` — The signed checkpoint to verify.
222/// * `trust_root` — The pinned trust root for the log that produced it.
223///
224/// Usage:
225/// ```ignore
226/// let status = verify_checkpoint_signature(&signed, &trust_root);
227/// ```
228pub fn verify_checkpoint_signature(
229    signed: &SignedCheckpoint,
230    trust_root: &TrustRoot,
231) -> CheckpointStatus {
232    verify_checkpoint(signed, trust_root)
233}
234
235/// Verify only the witness-cosignature dimension of a [`SignedCheckpoint`] against a
236/// [`TrustRoot`], returning the quorum + independence verdict.
237///
238/// Each cosignature is checked against a trusted witness (matched in constant time)
239/// and the present cosigning quorum is evaluated for independence under the trust
240/// root's policy. Exposed for the monitor, which holds a checkpoint without
241/// assembling a full [`OfflineBundle`] and must not re-implement Ed25519 cosignature
242/// verification itself.
243///
244/// Args:
245/// * `signed` — The signed checkpoint whose witness cosignatures to verify.
246/// * `trust_root` — The pinned trust root (witness roster + independence policy).
247///
248/// Usage:
249/// ```ignore
250/// let status = verify_witness_cosignatures(&signed, &trust_root);
251/// ```
252pub 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    // Independence is evaluated over the ACTUAL cosigning quorum — the witnesses
268    // whose cosignatures verify here — not the configured roster. A diverse roster
269    // with a correlated quorum must still fail.
270    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                // A verified cosigner without pinned attributes cannot contribute
289                // to diversity (fail closed under a real policy).
290                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    // Count met — require the present cosigners to be independent. Under the
302    // unconstrained default policy this passes trivially (0 ≥ 0), preserving
303    // legacy behavior; under a pinned commons policy it enforces diversity.
304    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)] // INVARIANT: 90 days always fits in Duration
321    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            // NamespaceDelegate can repeat (multi-hop delegation)
365        } 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    // INVARIANT: actor_did from a parsed Entry is already valid
496    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                // INVARIANT: actor_did from a parsed Entry is already valid
528                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        // 2-link chain: [DeviceBind, NamespaceClaim] — direct ownership, no org
539        #[allow(clippy::disallowed_methods)]
540        // INVARIANT: actor_did from a parsed Entry is already valid
541        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                // INVARIANT: actor_did from a parsed Entry is already valid
564                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    /// Build a 2-witness checkpoint cosigned by both, with the given operator
892    /// organizations and diversity thresholds, and return the witness verdict.
893    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        // 2 orgs / 2 jurisdictions / 2 infra zones meets a 2/2/2 policy.
975        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        // Count is met (2 ≥ 2) but both cosigners share an org → NotIndependent.
985        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}