Skip to main content

auths_verifier/
commit_bundle.rs

1//! Stateless commit verification against an identity bundle — no git, no
2//! identity store, no network.
3//!
4//! An exported identity bundle carries everything a verifier with nothing
5//! installed needs to decide "commit ← maintainer": the identity's KEL, one
6//! CESR signature attachment per event, and a freshness window. This module
7//! turns that bundle into a [`BundleTrust`] — a parsed trust anchor whose
8//! existence proves the bundle was fresh, self-certifying (RT-005), and
9//! signature-authenticated (RT-002) — and then verifies a raw commit object
10//! against it with [`verify_commit_against_kel`]. The same path serves the CLI
11//! (`--identity-bundle` on a bare CI runner) and the browser (the
12//! `verifyCommitJson` WASM export), so the verdict cannot drift between
13//! transports.
14
15use auths_keri::{
16    Event, KelSealIndex, compute_event_said, pair_kel_attachments, validate_signed_kel,
17};
18use chrono::{DateTime, Utc};
19use serde::Serialize;
20
21use crate::commit_kel::{CommitVerdict, commit_signer_trailers, verify_commit_against_kel};
22use crate::core::{IdentityBundle, MAX_JSON_BATCH_SIZE};
23use crate::freshness::{Freshness, FreshnessEvidence, FreshnessPolicy};
24use auths_crypto::CryptoProvider;
25
26/// Why an identity bundle could not become a trust anchor. Fails closed: an
27/// unusable bundle is rejected, never silently treated as "no constraint".
28#[derive(Debug, thiserror::Error)]
29pub enum BundleTrustError {
30    /// The bundle is past its own TTL.
31    #[error("bundle is stale: {0}")]
32    Stale(String),
33
34    /// RT-005: the bundle's `identity_did` does not name the inception its KEL
35    /// carries, so it could pair a victim's DID with an attacker-authored KEL.
36    #[error("bundle does not self-certify: {0}")]
37    NotSelfCertifying(String),
38
39    /// RT-002: the bundle's KEL events could not be authenticated against the
40    /// controlling key-state (missing, malformed, or forged signature
41    /// attachments). Re-export with a current `auths id export-bundle`.
42    #[error("bundle KEL failed signature authentication (RT-002): {0}")]
43    KelUnauthenticated(String),
44}
45
46/// A parsed, authenticated trust anchor extracted from an identity bundle.
47///
48/// Constructing one ([`BundleTrust::parse`]) proves three things, so callers
49/// never re-check them:
50///
51/// 1. **Freshness** — the bundle is within its own TTL.
52/// 2. **Self-certification (RT-005)** — the bundle's `identity_did` names the
53///    inception event its KEL carries (SAID for `E…` prefixes, controller
54///    field otherwise), so a bundle cannot pair a victim's DID with an
55///    attacker-authored KEL.
56/// 3. **KEL authentication (RT-002)** — every KEL event is signed by its
57///    controlling key-state, verified via [`validate_signed_kel`]; a stripped
58///    or forged attachment fails closed here.
59///
60/// The bundle is *evidence for* a pinned root, never the source of the pin:
61/// callers must still require [`BundleTrust::root_did`] to be in their
62/// independently pinned root set.
63pub struct BundleTrust {
64    root_did: String,
65    kel: Vec<Event>,
66    device_kels: Vec<AuthenticatedDeviceKel>,
67}
68
69/// One authenticated device KEL from a bundle: the device's `did:keri:` and its
70/// seal-checked events, oldest first.
71pub type AuthenticatedDeviceKel = (String, Vec<Event>);
72
73impl BundleTrust {
74    /// Parse a bundle into a trust anchor: freshness + RT-005 + RT-002.
75    ///
76    /// Args:
77    /// * `bundle`: The deserialized identity bundle (attacker-controlled input).
78    /// * `now`: Current time, injected at the boundary.
79    ///
80    /// Usage:
81    /// ```ignore
82    /// let trust = BundleTrust::parse(&bundle, Utc::now())?;
83    /// assert!(pinned_roots.contains(&trust.root_did().to_string()));
84    /// ```
85    pub fn parse(bundle: &IdentityBundle, now: DateTime<Utc>) -> Result<Self, BundleTrustError> {
86        bundle
87            .check_freshness(now)
88            .map_err(|e| BundleTrustError::Stale(e.to_string()))?;
89
90        // RT-005 — self-certification: the DID the caller is about to treat as
91        // a root MUST actually name the inception the bundle carries. For a
92        // self-addressing (`E`) root the DID MUST equal the inception SAID; for
93        // a basic-derivation root it MUST equal the inception's controller
94        // field (and replay independently enforces `i == k[0]`).
95        if let Some(inception) = bundle.kel.first() {
96            let claimed = bundle.identity_did.to_string();
97            let prefix = claimed
98                .strip_prefix("did:keri:")
99                .unwrap_or(claimed.as_str());
100            if prefix.starts_with('E') {
101                let said = compute_event_said(inception).map_err(|e| {
102                    BundleTrustError::NotSelfCertifying(format!(
103                        "bundle KEL inception has no computable SAID: {e}"
104                    ))
105                })?;
106                if prefix != said.as_str() {
107                    return Err(BundleTrustError::NotSelfCertifying(format!(
108                        "bundle identity_did {claimed} does not self-certify to its \
109                         inception SAID did:keri:{said}"
110                    )));
111                }
112            } else {
113                let inception_i = inception.prefix().as_str();
114                if prefix != inception_i {
115                    return Err(BundleTrustError::NotSelfCertifying(format!(
116                        "bundle identity_did {claimed} does not match its inception \
117                         controller {inception_i}"
118                    )));
119                }
120            }
121        }
122
123        // RT-002 — authenticate the KEL: every event must carry a valid CESR
124        // signature from its controlling key-state. The count-mismatch refusal
125        // (absent/short attachment list ⇒ unauthenticated KEL) lives once, in
126        // `pair_kel_attachments` — never re-implemented here.
127        if !bundle.kel.is_empty() {
128            let attachment_bytes: Vec<Vec<u8>> = bundle
129                .kel_attachments
130                .iter()
131                .map(|att_hex| {
132                    hex::decode(att_hex).map_err(|e| {
133                        BundleTrustError::KelUnauthenticated(format!(
134                            "non-hex KEL signature attachment: {e}"
135                        ))
136                    })
137                })
138                .collect::<Result<_, _>>()?;
139            let signed = pair_kel_attachments(bundle.kel.clone(), &attachment_bytes)
140                .map_err(|e| BundleTrustError::KelUnauthenticated(e.to_string()))?;
141            validate_signed_kel(&signed, None)
142                .map_err(|e| BundleTrustError::KelUnauthenticated(e.to_string()))?;
143        }
144
145        // Device KELs authenticate the same way, with the just-authenticated
146        // root KEL resolving each delegation seal. Self-certification first: a
147        // bundle cannot pair a victim's device DID with an attacker-authored
148        // KEL any more than it can for the root.
149        let root_seals = KelSealIndex::from_events(&bundle.kel);
150        let mut device_kels = Vec::with_capacity(bundle.device_kels.len());
151        for device in &bundle.device_kels {
152            let prefix = device
153                .did
154                .strip_prefix("did:keri:")
155                .unwrap_or(device.did.as_str());
156            let Some(inception) = device.kel.first() else {
157                return Err(BundleTrustError::KelUnauthenticated(format!(
158                    "device {} carries an empty KEL",
159                    device.did
160                )));
161            };
162            let said = compute_event_said(inception).map_err(|e| {
163                BundleTrustError::NotSelfCertifying(format!(
164                    "device {} KEL inception has no computable SAID: {e}",
165                    device.did
166                ))
167            })?;
168            if prefix != said.as_str() {
169                return Err(BundleTrustError::NotSelfCertifying(format!(
170                    "device did {} does not self-certify to its inception SAID did:keri:{said}",
171                    device.did
172                )));
173            }
174            let attachment_bytes: Vec<Vec<u8>> = device
175                .kel_attachments
176                .iter()
177                .map(|att_hex| {
178                    hex::decode(att_hex).map_err(|e| {
179                        BundleTrustError::KelUnauthenticated(format!(
180                            "non-hex device KEL signature attachment: {e}"
181                        ))
182                    })
183                })
184                .collect::<Result<_, _>>()?;
185            let signed = pair_kel_attachments(device.kel.clone(), &attachment_bytes)
186                .map_err(|e| BundleTrustError::KelUnauthenticated(e.to_string()))?;
187            validate_signed_kel(&signed, Some(&root_seals))
188                .map_err(|e| BundleTrustError::KelUnauthenticated(e.to_string()))?;
189            // Keep the rehydrated events: pairing restored each delegated
190            // event's -G source seal, which downstream structural replays
191            // re-enforce against the root KEL.
192            device_kels.push((
193                device.did.clone(),
194                signed.into_iter().map(|se| se.event).collect(),
195            ));
196        }
197
198        Ok(Self {
199            root_did: bundle.identity_did.to_string(),
200            kel: bundle.kel.clone(),
201            device_kels,
202        })
203    }
204
205    /// The root `did:keri:` this bundle self-certifies to. Evidence for a pin,
206    /// never the pin itself.
207    pub fn root_did(&self) -> &str {
208        &self.root_did
209    }
210
211    /// The authenticated KEL events, oldest first.
212    pub fn kel(&self) -> &[Event] {
213        &self.kel
214    }
215
216    /// The authenticated device KELs — `(did, events)` per delegated device the
217    /// bundle carries, each seal-checked against the root KEL.
218    pub fn device_kels(&self) -> &[AuthenticatedDeviceKel] {
219        &self.device_kels
220    }
221
222    /// Consume the anchor into `(root_did, kel, device_kels)` for callers that
223    /// thread the parts separately (the CLI's stateless resolver).
224    pub fn into_parts(self) -> (String, Vec<Event>, Vec<AuthenticatedDeviceKel>) {
225        (self.root_did, self.kel, self.device_kels)
226    }
227}
228
229/// The tagged JSON envelope returned by [`verify_commit_with_bundle_json`]:
230/// `kind: "verdict"` carries the commit verdict; `kind: "error"` means the
231/// inputs never reached a verdict (unusable bundle, unpinned root, bad JSON).
232#[derive(Serialize)]
233#[serde(tag = "kind", rename_all = "lowercase")]
234enum CommitBundleEnvelope {
235    Verdict {
236        valid: bool,
237        verdict: &'static str,
238        detail: String,
239        #[serde(skip_serializing_if = "Option::is_none")]
240        signer_did: Option<String>,
241        #[serde(skip_serializing_if = "Option::is_none")]
242        root_did: Option<String>,
243        /// The freshness grade of a positive verdict; the relying party's policy, not the
244        /// bundle's TTL, decides whether it clears (ADR 009).
245        #[serde(skip_serializing_if = "Option::is_none")]
246        freshness: Option<Freshness>,
247        /// The signer-KEL tip the verdict was decided against.
248        #[serde(skip_serializing_if = "Option::is_none")]
249        as_of: Option<u128>,
250    },
251    Error {
252        error: String,
253    },
254}
255
256fn envelope_to_string(envelope: &CommitBundleEnvelope) -> String {
257    serde_json::to_string(envelope)
258        .unwrap_or_else(|_| r#"{"kind":"error","error":"serialization failed"}"#.to_string())
259}
260
261/// A stable machine code for each [`CommitVerdict`] variant. Thin alias over the
262/// canonical [`CommitVerdict::code`] so the bundle-JSON `kind` and the CLI `status`
263/// field share one source of truth.
264fn verdict_code(verdict: &CommitVerdict) -> &'static str {
265    verdict.code()
266}
267
268/// A human-readable one-liner for each [`CommitVerdict`] variant.
269fn verdict_detail(verdict: &CommitVerdict) -> String {
270    match verdict {
271        CommitVerdict::Valid {
272            signer_did,
273            root_did,
274            duplicitous_root,
275            ..
276        } => {
277            let fork = if *duplicitous_root {
278                " (warning: root KEL shows a fork)"
279            } else {
280                ""
281            };
282            format!("commit signed by {signer_did}, chained to pinned root {root_did}{fork}")
283        }
284        CommitVerdict::Unsigned => "commit carries no SSH signature".to_string(),
285        CommitVerdict::SshSignatureInvalid => "SSH signature did not validate".to_string(),
286        CommitVerdict::GpgUnsupported => "PGP-signed commit (unsupported)".to_string(),
287        CommitVerdict::DeviceKelInvalid(e) => format!("device KEL invalid: {e}"),
288        CommitVerdict::RootKelInvalid(e) => format!("root KEL invalid: {e}"),
289        CommitVerdict::RootNotPinned(did) => format!("root {did} is not pinned"),
290        CommitVerdict::RootAbandoned => "root identity is abandoned".to_string(),
291        CommitVerdict::NotDelegatedByClaimedRoot {
292            device_did,
293            root_did,
294        } => format!("{device_did} is not delegated by {root_did}"),
295        CommitVerdict::DelegationSealNotFound => {
296            "root never anchored the device's delegation".to_string()
297        }
298        CommitVerdict::DeviceRevoked => "the signer's delegation is revoked".to_string(),
299        CommitVerdict::SignedAfterRevocation {
300            signed_at,
301            revoked_at,
302            ..
303        } => format!("signed at KEL position {signed_at}, revoked at {revoked_at}"),
304        CommitVerdict::OutsideAgentScope { capability, .. } => {
305            format!("capability {capability} is outside the agent's anchored scope")
306        }
307        CommitVerdict::AgentExpired { expired_at, .. } => {
308            format!("agent delegation expired at {expired_at}")
309        }
310        CommitVerdict::SignerKeyMismatch => {
311            "SSH signer key is not the device's current key".to_string()
312        }
313        CommitVerdict::SignedBySupersededKey => {
314            "SSH signer key was superseded by a device rotation".to_string()
315        }
316        CommitVerdict::WitnessQuorumNotMet {
317            collected,
318            required,
319            ..
320        } => format!("witness quorum not met: {collected} of {required}"),
321    }
322}
323
324fn verdict_envelope(verdict: CommitVerdict) -> CommitBundleEnvelope {
325    let (signer_did, root_did, as_of, freshness) = match &verdict {
326        CommitVerdict::Valid {
327            signer_did,
328            root_did,
329            as_of,
330            freshness,
331            ..
332        } => (
333            Some(signer_did.clone()),
334            Some(root_did.clone()),
335            Some(*as_of),
336            Some(*freshness),
337        ),
338        _ => (None, None, None, None),
339    };
340    CommitBundleEnvelope::Verdict {
341        // The relying party's policy caps trust, not the bundle's self-declared TTL: a bundle
342        // older than the default window is not trusted even though it verified (ADR 009).
343        valid: verdict.is_trusted(&FreshnessPolicy::default()),
344        verdict: verdict_code(&verdict),
345        detail: verdict_detail(&verdict),
346        signer_did,
347        root_did,
348        freshness,
349        as_of,
350    }
351}
352
353/// Verify a raw git commit object against an identity bundle, fully stateless,
354/// returning the tagged JSON envelope (`kind`: `"verdict"` | `"error"`).
355///
356/// The bundle is attacker-controlled input: it is parsed into a
357/// [`BundleTrust`] (freshness + RT-005 self-certification + RT-002 KEL
358/// authentication) and is **evidence only** — its root must already be in
359/// `pinned_roots_json` or verification refuses before any signature check.
360/// The commit's `Auths-Id`/`Auths-Device` trailers may only *select* the
361/// bundle identity: a trailer naming any other DID cannot be resolved without
362/// an identity store and fails closed.
363///
364/// Args:
365/// * `commit_text`: The raw git commit object (headers + message + `gpgsig`),
366///   exactly as produced by `git cat-file commit <sha>`.
367/// * `bundle_json`: The identity bundle JSON (from `auths id export-bundle`).
368/// * `pinned_roots_json`: JSON array of independently pinned `did:keri:` roots.
369/// * `now`: Current time, injected at the boundary.
370/// * `provider`: Crypto provider for in-process SSH-signature verification.
371pub async fn verify_commit_with_bundle_json(
372    commit_text: &str,
373    bundle_json: &str,
374    pinned_roots_json: &str,
375    now: DateTime<Utc>,
376    provider: &dyn CryptoProvider,
377) -> String {
378    match verify_commit_with_bundle_inner(
379        commit_text,
380        bundle_json,
381        pinned_roots_json,
382        now,
383        provider,
384    )
385    .await
386    {
387        Ok(verdict) => envelope_to_string(&verdict_envelope(verdict)),
388        Err(error) => envelope_to_string(&CommitBundleEnvelope::Error { error }),
389    }
390}
391
392async fn verify_commit_with_bundle_inner(
393    commit_text: &str,
394    bundle_json: &str,
395    pinned_roots_json: &str,
396    now: DateTime<Utc>,
397    provider: &dyn CryptoProvider,
398) -> Result<CommitVerdict, String> {
399    for (name, input) in [
400        ("commit", commit_text),
401        ("bundle", bundle_json),
402        ("pinned roots", pinned_roots_json),
403    ] {
404        if input.len() > MAX_JSON_BATCH_SIZE {
405            return Err(format!(
406                "{name} input too large: {} bytes, max {MAX_JSON_BATCH_SIZE}",
407                input.len()
408            ));
409        }
410    }
411
412    let bundle: IdentityBundle = serde_json::from_str(bundle_json)
413        .map_err(|e| format!("identity bundle is not valid JSON: {e}"))?;
414    let pinned_roots: Vec<String> = serde_json::from_str(pinned_roots_json)
415        .map_err(|e| format!("pinned roots is not a JSON array of DIDs: {e}"))?;
416
417    let trust = BundleTrust::parse(&bundle, now).map_err(|e| e.to_string())?;
418
419    // Evidence-only (RT-005): the bundle never becomes its own trust anchor —
420    // otherwise the anchor and the evidence both come from the same
421    // attacker-supplied input.
422    if !pinned_roots.iter().any(|r| r == trust.root_did()) {
423        return Err(format!(
424            "bundle root {} is not independently pinned: a bundle is evidence \
425             for a pinned root, never the source of the pin",
426            trust.root_did()
427        ));
428    }
429
430    let (root_did, device_did) = commit_signer_trailers(commit_text).ok_or_else(|| {
431        "commit carries no Auths-Id/Auths-Device trailer — it was not signed by auths".to_string()
432    })?;
433
434    // Stateless resolution: the bundle carries exactly one identity's KEL, so a
435    // trailer may only select that identity. Anything else needs an identity
436    // store and fails closed here.
437    for (role, did) in [("root", &root_did), ("device", &device_did)] {
438        if did != trust.root_did() {
439            return Err(format!(
440                "{role} KEL for {did} is not carried by the bundle (bundle \
441                 identity is {}); stateless verification resolves only the \
442                 bundle identity",
443                trust.root_did()
444            ));
445        }
446    }
447
448    let verdict = verify_commit_against_kel(
449        commit_text.as_bytes(),
450        trust.kel(),
451        trust.kel(),
452        &pinned_roots,
453        provider,
454    )
455    .await;
456
457    // An offline bundle carries no source the verifier can trust to confirm freshness: its
458    // timestamp and TTL are producer-set, unsigned fields, so an edited or inflated value must not
459    // buy trust. Absent a verifier-supplied fresher tip (a witness head / checkpoint / log tip),
460    // the strongest honest grade is Unknown — the relying party's policy decides whether to
461    // tolerate it (ADR 009 D5).
462    Ok(verdict.with_freshness(&FreshnessPolicy::default(), FreshnessEvidence::Offline))
463}
464
465#[cfg(test)]
466#[allow(clippy::unwrap_used, clippy::expect_used)]
467mod tests {
468    use super::*;
469    use crate::core::PublicKeyHex;
470    use crate::types::IdentityDID;
471
472    const ROOT: &str = "did:keri:Eroot00000000000000000000000000000000000000";
473
474    fn test_bundle(did: &str, ts: DateTime<Utc>, ttl: u64) -> IdentityBundle {
475        IdentityBundle {
476            identity_did: IdentityDID::new_unchecked(did.to_string()),
477            public_key_hex: PublicKeyHex::new_unchecked("ab".repeat(32)),
478            curve: auths_crypto::CurveType::P256,
479            attestation_chain: Vec::new(),
480            kel: Vec::new(),
481            kel_attachments: Vec::new(),
482            device_kels: Vec::new(),
483            bundle_timestamp: ts,
484            max_valid_for_secs: ttl,
485        }
486    }
487
488    fn fixed_time() -> DateTime<Utc> {
489        DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid timestamp")
490    }
491
492    #[test]
493    fn fresh_bundle_parses_to_its_root_did() {
494        let t = fixed_time();
495        let bundle = test_bundle(ROOT, t, 3600);
496        let now = t + chrono::Duration::seconds(100);
497        let trust = BundleTrust::parse(&bundle, now).expect("fresh");
498        assert_eq!(trust.root_did(), ROOT);
499        assert!(trust.kel().is_empty());
500    }
501
502    #[test]
503    fn stale_bundle_fails_closed() {
504        let t = fixed_time();
505        let bundle = test_bundle(ROOT, t, 3600);
506        let now = t + chrono::Duration::seconds(7200);
507        assert!(matches!(
508            BundleTrust::parse(&bundle, now),
509            Err(BundleTrustError::Stale(_))
510        ));
511    }
512
513    #[test]
514    fn bundle_rejects_did_not_matching_its_kel_inception() {
515        // RT-005 self-certification: a bundle that pairs a DID with a KEL whose
516        // inception names a DIFFERENT controller must fail closed, so a bundle
517        // can never become the trust anchor for an attacker-authored KEL.
518        use auths_keri::{
519            CesrKey, Event, IcpEvent, KeriPublicKey, KeriSequence, Prefix, Said, Threshold,
520            VersionString, compute_next_commitment, finalize_icp_event,
521        };
522        let key = KeriPublicKey::ed25519(&[7u8; 32]).unwrap();
523        let next = KeriPublicKey::ed25519(&[8u8; 32]).unwrap();
524        let inception = finalize_icp_event(IcpEvent {
525            v: VersionString::placeholder(),
526            d: Said::default(),
527            i: Prefix::default(),
528            s: KeriSequence::new(0),
529            kt: Threshold::Simple(1),
530            k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())],
531            nt: Threshold::Simple(1),
532            n: vec![compute_next_commitment(&next)],
533            bt: Threshold::Simple(0),
534            b: vec![],
535            c: vec![],
536            a: vec![],
537        })
538        .unwrap();
539        let t = fixed_time();
540        // Pair that inception with an unrelated `D…` DID it does not certify.
541        let mut bundle = test_bundle("did:keri:DAttackerKey", t, 3600);
542        bundle.kel = vec![Event::Icp(inception)];
543        let now = t + chrono::Duration::seconds(100);
544        assert!(matches!(
545            BundleTrust::parse(&bundle, now),
546            Err(BundleTrustError::NotSelfCertifying(_))
547        ));
548    }
549
550    #[test]
551    fn bundle_with_stripped_attachments_fails_rt002() {
552        // RT-002: a KEL without its signature attachments is unauthenticated —
553        // refused outright, never degraded to a structural-only replay.
554        use auths_keri::{
555            CesrKey, Event, IcpEvent, KeriPublicKey, KeriSequence, Prefix, Said, Threshold,
556            VersionString, compute_next_commitment, finalize_icp_event,
557        };
558        let key = KeriPublicKey::ed25519(&[7u8; 32]).unwrap();
559        let next = KeriPublicKey::ed25519(&[8u8; 32]).unwrap();
560        let inception = finalize_icp_event(IcpEvent {
561            v: VersionString::placeholder(),
562            d: Said::default(),
563            i: Prefix::default(),
564            s: KeriSequence::new(0),
565            kt: Threshold::Simple(1),
566            k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())],
567            nt: Threshold::Simple(1),
568            n: vec![compute_next_commitment(&next)],
569            bt: Threshold::Simple(0),
570            b: vec![],
571            c: vec![],
572            a: vec![],
573        })
574        .unwrap();
575        let did = format!("did:keri:{}", inception.d.as_str());
576        let t = fixed_time();
577        let mut bundle = test_bundle(&did, t, 3600);
578        bundle.kel = vec![Event::Icp(inception)];
579        // kel_attachments stays empty: self-certifies (RT-005 passes) but
580        // cannot be authenticated (RT-002 fails).
581        let now = t + chrono::Duration::seconds(100);
582        assert!(matches!(
583            BundleTrust::parse(&bundle, now),
584            Err(BundleTrustError::KelUnauthenticated(_))
585        ));
586    }
587
588    #[tokio::test]
589    async fn unpinned_bundle_root_is_an_error_envelope() {
590        // Evidence-only: a coherent bundle whose root is not pinned must refuse
591        // before any signature work.
592        let t = fixed_time();
593        let bundle = test_bundle(ROOT, t, 3600);
594        let bundle_json = serde_json::to_string(&bundle).unwrap();
595        let out = verify_commit_with_bundle_json(
596            "tree abc\n\nsubject\n",
597            &bundle_json,
598            "[]",
599            t + chrono::Duration::seconds(10),
600            &auths_crypto::RingCryptoProvider,
601        )
602        .await;
603        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
604        assert_eq!(v["kind"], "error");
605        assert!(
606            v["error"]
607                .as_str()
608                .unwrap()
609                .contains("not independently pinned"),
610            "unexpected error: {out}"
611        );
612    }
613
614    #[tokio::test]
615    async fn trailerless_commit_is_an_error_envelope() {
616        let t = fixed_time();
617        let bundle = test_bundle(ROOT, t, 3600);
618        let bundle_json = serde_json::to_string(&bundle).unwrap();
619        let pinned = format!("[\"{ROOT}\"]");
620        let out = verify_commit_with_bundle_json(
621            "tree abc\n\nsubject, no trailers\n",
622            &bundle_json,
623            &pinned,
624            t + chrono::Duration::seconds(10),
625            &auths_crypto::RingCryptoProvider,
626        )
627        .await;
628        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
629        assert_eq!(v["kind"], "error");
630        assert!(
631            v["error"].as_str().unwrap().contains("Auths-Id"),
632            "unexpected error: {out}"
633        );
634    }
635
636    #[tokio::test]
637    async fn foreign_trailer_did_fails_closed() {
638        // A commit claiming a signer the bundle does not carry cannot be
639        // resolved statelessly — refused, never silently verified against the
640        // bundle's unrelated KEL.
641        let t = fixed_time();
642        let bundle = test_bundle(ROOT, t, 3600);
643        let bundle_json = serde_json::to_string(&bundle).unwrap();
644        let pinned = format!("[\"{ROOT}\"]");
645        let commit =
646            "tree abc\n\nsubject\n\nAuths-Id: did:keri:Eother\nAuths-Device: did:keri:Eother\n";
647        let out = verify_commit_with_bundle_json(
648            commit,
649            &bundle_json,
650            &pinned,
651            t + chrono::Duration::seconds(10),
652            &auths_crypto::RingCryptoProvider,
653        )
654        .await;
655        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
656        assert_eq!(v["kind"], "error");
657        assert!(
658            v["error"]
659                .as_str()
660                .unwrap()
661                .contains("not carried by the bundle"),
662            "unexpected error: {out}"
663        );
664    }
665
666    fn valid_verdict() -> CommitVerdict {
667        CommitVerdict::Valid {
668            signer_did: "did:keri:Edev".to_string(),
669            root_did: ROOT.to_string(),
670            duplicitous_root: false,
671            as_of: 7,
672            freshness: Freshness::Unknown,
673        }
674    }
675
676    #[test]
677    fn bundle_age_grades_the_commit_verdict_against_the_verifier_policy() {
678        let policy = FreshnessPolicy::default(); // 24h
679        // A bundle older than the policy window grades Stale and is trusted by no policy —
680        // the verifier caps trust regardless of the bundle's self-declared TTL.
681        let stale = valid_verdict().with_freshness(
682            &policy,
683            FreshnessEvidence::SourceAge(std::time::Duration::from_secs(25 * 3600)),
684        );
685        assert_eq!(stale.freshness(), Freshness::Stale);
686        assert!(!stale.is_trusted(&policy));
687        // A bundle within the window grades Fresh and is trusted.
688        let fresh = valid_verdict().with_freshness(
689            &policy,
690            FreshnessEvidence::SourceAge(std::time::Duration::from_secs(3600)),
691        );
692        assert_eq!(fresh.freshness(), Freshness::Fresh);
693        assert!(fresh.is_trusted(&policy));
694        // No oracle (a direct verify) → Unknown: the default tolerates it, strict denies it.
695        let unknown = valid_verdict().with_freshness(&policy, FreshnessEvidence::Offline);
696        assert_eq!(unknown.freshness(), Freshness::Unknown);
697        assert!(unknown.is_trusted(&policy));
698        assert!(
699            !unknown.is_trusted(&FreshnessPolicy::strict(std::time::Duration::from_secs(
700                3600
701            )))
702        );
703    }
704
705    #[test]
706    fn verdict_envelope_caps_trust_at_the_policy_and_surfaces_freshness() {
707        // A stale-graded verdict: the envelope reports it not-valid (the verifier policy caps
708        // trust) and surfaces the freshness grade and as-of for the consumer.
709        let stale = valid_verdict().with_freshness(
710            &FreshnessPolicy::default(),
711            FreshnessEvidence::SourceAge(std::time::Duration::from_secs(25 * 3600)),
712        );
713        let json = envelope_to_string(&verdict_envelope(stale));
714        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
715        assert_eq!(
716            v["valid"], false,
717            "a stale bundle is not trusted under the default policy"
718        );
719        assert_eq!(v["freshness"], "stale");
720        assert_eq!(v["as_of"], 7);
721
722        // A fresh-graded verdict: trusted and surfaced.
723        let fresh = valid_verdict().with_freshness(
724            &FreshnessPolicy::default(),
725            FreshnessEvidence::SourceAge(std::time::Duration::from_secs(3600)),
726        );
727        let v: serde_json::Value =
728            serde_json::from_str(&envelope_to_string(&verdict_envelope(fresh))).unwrap();
729        assert_eq!(v["valid"], true);
730        assert_eq!(v["freshness"], "fresh");
731    }
732}