1use 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#[derive(Debug, thiserror::Error)]
29pub enum BundleTrustError {
30 #[error("bundle is stale: {0}")]
32 Stale(String),
33
34 #[error("bundle does not self-certify: {0}")]
37 NotSelfCertifying(String),
38
39 #[error("bundle KEL failed signature authentication (RT-002): {0}")]
43 KelUnauthenticated(String),
44}
45
46pub struct BundleTrust {
64 root_did: String,
65 kel: Vec<Event>,
66 device_kels: Vec<AuthenticatedDeviceKel>,
67}
68
69pub type AuthenticatedDeviceKel = (String, Vec<Event>);
72
73impl BundleTrust {
74 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 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 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 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 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 pub fn root_did(&self) -> &str {
208 &self.root_did
209 }
210
211 pub fn kel(&self) -> &[Event] {
213 &self.kel
214 }
215
216 pub fn device_kels(&self) -> &[AuthenticatedDeviceKel] {
219 &self.device_kels
220 }
221
222 pub fn into_parts(self) -> (String, Vec<Event>, Vec<AuthenticatedDeviceKel>) {
225 (self.root_did, self.kel, self.device_kels)
226 }
227}
228
229#[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 #[serde(skip_serializing_if = "Option::is_none")]
246 freshness: Option<Freshness>,
247 #[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
261fn verdict_code(verdict: &CommitVerdict) -> &'static str {
265 verdict.code()
266}
267
268fn 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 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
353pub 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 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 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 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 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 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 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 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 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 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(); 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 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 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 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 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}