1use auths_keri::Prefix;
19use auths_keri::witness::independence::HonestyCeiling;
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22
23use crate::core::Ed25519PublicKey;
24use crate::org_bundle::{
25 AirGappedOrgBundle, AuthorityAtSigning, classify_authority_in_bundle, verify_org_bundle,
26};
27use crate::tlog::{ConsistencyProof, InclusionProof, MerkleHash, SignedCheckpoint, hash_leaf};
28use crate::types::IdentityDID;
29
30pub const EVIDENCE_PACK_SCHEMA_VERSION: u32 = 1;
32
33pub const MAX_PACK_JSON_BYTES: usize = 16 * 1024 * 1024;
37
38#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum EvidencePackError {
42 #[error("canonicalization failed: {0}")]
44 Canonicalize(String),
45 #[error("decode failed: {0}")]
47 Decode(String),
48 #[error("offline verification failed: {0}")]
51 OfflineVerification(String),
52}
53
54impl auths_crypto::AuthsErrorInfo for EvidencePackError {
55 fn error_code(&self) -> &'static str {
56 match self {
57 Self::Canonicalize(_) => "AUTHS-E2301",
58 Self::Decode(_) => "AUTHS-E2302",
59 Self::OfflineVerification(_) => "AUTHS-E2303",
60 }
61 }
62
63 fn suggestion(&self) -> Option<&'static str> {
64 match self {
65 Self::Canonicalize(_) | Self::Decode(_) => Some(
66 "The file is not a valid evidence pack; re-export it with `auths compliance report`",
67 ),
68 Self::OfflineVerification(_) => Some(
69 "The pack failed offline verification; obtain a fresh, untampered pack from the org",
70 ),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum ComplianceFramework {
81 Slsa,
83 Sbom,
85 Cra,
87 Soc2,
89 Iso27001,
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct TransparencyInclusion {
103 pub leaf_hash: MerkleHash,
110 pub inclusion_proof: InclusionProof,
112 pub signed_checkpoint: SignedCheckpoint,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub consistency_proof: Option<ConsistencyProof>,
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct EvidenceRow {
127 pub artifact_digest: String,
129 pub signer: IdentityDID,
131 pub authority_at_release: AuthorityAtSigning,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub signed_at: Option<u128>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub transparency: Option<TransparencyInclusion>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct EvidencePack {
145 pub schema_version: u32,
147 pub org: IdentityDID,
149 pub period: String,
151 pub framework: ComplianceFramework,
153 pub equivocation_visibility: HonestyCeiling,
157 pub generated_at: DateTime<Utc>,
160 pub rows: Vec<EvidenceRow>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub org_bundle: Option<AirGappedOrgBundle>,
167}
168
169impl EvidencePack {
170 pub fn canonicalize(&self) -> Result<String, EvidencePackError> {
173 json_canon::to_string(self).map_err(|e| EvidencePackError::Canonicalize(e.to_string()))
174 }
175
176 pub fn from_json(json: &str) -> Result<Self, EvidencePackError> {
179 serde_json::from_str(json).map_err(|e| EvidencePackError::Decode(e.to_string()))
180 }
181
182 pub fn to_intoto_statement(&self) -> Result<String, EvidencePackError> {
186 let subject: Vec<serde_json::Value> = self
187 .rows
188 .iter()
189 .map(|r| {
190 let digest = r
191 .artifact_digest
192 .strip_prefix("sha256:")
193 .unwrap_or(&r.artifact_digest);
194 serde_json::json!({
195 "name": r.artifact_digest,
196 "digest": { "sha256": digest },
197 })
198 })
199 .collect();
200
201 let statement = serde_json::json!({
202 "_type": "https://in-toto.io/Statement/v1",
203 "subject": subject,
204 "predicateType": "https://auths.dev/compliance/evidence/v1",
205 "predicate": self,
206 });
207
208 json_canon::to_string(&statement)
209 .map_err(|e| EvidencePackError::Canonicalize(e.to_string()))
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize)]
215pub struct RowVerdict {
216 pub artifact_digest: String,
218 pub signer: IdentityDID,
220 pub authority_at_release: AuthorityAtSigning,
222 pub authority_consistent: bool,
225 pub transparency_verified: Option<bool>,
229 pub checkpoint_attested: Option<bool>,
236}
237
238pub fn verify_transparency_inclusion(t: &TransparencyInclusion) -> Result<(), EvidencePackError> {
240 t.inclusion_proof.verify(&t.leaf_hash).map_err(|e| {
241 EvidencePackError::OfflineVerification(format!("inclusion proof did not verify: {e}"))
242 })?;
243
244 let checkpoint_root = t.signed_checkpoint.checkpoint.root;
245 let checkpoint_size = t.signed_checkpoint.checkpoint.size;
246
247 match &t.consistency_proof {
248 Some(c) => {
249 if c.old_root != t.inclusion_proof.root || c.old_size != t.inclusion_proof.size {
250 return Err(EvidencePackError::OfflineVerification(
251 "consistency proof old root/size does not match the inclusion proof".into(),
252 ));
253 }
254 if c.new_root != checkpoint_root || c.new_size != checkpoint_size {
255 return Err(EvidencePackError::OfflineVerification(
256 "consistency proof new root/size does not match the signed checkpoint".into(),
257 ));
258 }
259 c.verify().map_err(|e| {
260 EvidencePackError::OfflineVerification(format!(
261 "consistency proof did not verify: {e}"
262 ))
263 })?;
264 }
265 None => {
266 if t.inclusion_proof.root != checkpoint_root
267 || t.inclusion_proof.size != checkpoint_size
268 {
269 return Err(EvidencePackError::OfflineVerification(
270 "inclusion proof is not against the signed checkpoint and no consistency proof was provided".into(),
271 ));
272 }
273 }
274 }
275 Ok(())
276}
277
278fn verify_inclusion_binds_artifact(
283 artifact_digest: &str,
284 t: &TransparencyInclusion,
285) -> Result<(), EvidencePackError> {
286 if t.leaf_hash != hash_leaf(artifact_digest.as_bytes()) {
287 return Err(EvidencePackError::OfflineVerification(format!(
288 "transparency evidence is not for this artifact: leaf hash does not re-derive from {artifact_digest}"
289 )));
290 }
291 verify_transparency_inclusion(t)
292}
293
294pub fn verify_artifact_log_inclusion(
318 artifact_digest: &str,
319 t: &TransparencyInclusion,
320 pinned_log_key: &Ed25519PublicKey,
321) -> Result<(), EvidencePackError> {
322 verify_inclusion_binds_artifact(artifact_digest, t)?;
323 t.signed_checkpoint
324 .verify_log_signature(pinned_log_key)
325 .map_err(|e| {
326 EvidencePackError::OfflineVerification(format!(
327 "checkpoint is not attested by the pinned log key: {e}"
328 ))
329 })
330}
331
332pub fn verify_evidence_pack_offline(
358 pack: &EvidencePack,
359 pinned_roots: &[IdentityDID],
360 pinned_log_key: Option<&Ed25519PublicKey>,
361) -> Result<Vec<RowVerdict>, EvidencePackError> {
362 let bundle = pack.org_bundle.as_ref().ok_or_else(|| {
363 EvidencePackError::OfflineVerification(
364 "pack carries no embedded org bundle — not an offline-verifiable pack".into(),
365 )
366 })?;
367
368 let report = verify_org_bundle(bundle, pinned_roots, None)
369 .map_err(|e| EvidencePackError::OfflineVerification(e.to_string()))?;
370 if !report.root_pinned {
371 return Err(EvidencePackError::OfflineVerification(format!(
372 "org {} is not in the pinned trust roots",
373 bundle.org_did.as_str()
374 )));
375 }
376 if report.duplicity_detected {
377 return Err(EvidencePackError::OfflineVerification(
378 "org KEL shows duplicity (same-seq divergent SAIDs)".into(),
379 ));
380 }
381
382 let mut verdicts = Vec::with_capacity(pack.rows.len());
383 for row in &pack.rows {
384 let signer_prefix = Prefix::new_unchecked(
385 row.signer
386 .as_str()
387 .strip_prefix("did:keri:")
388 .unwrap_or(row.signer.as_str())
389 .to_string(),
390 );
391 let rederived = classify_authority_in_bundle(bundle, &signer_prefix, row.signed_at);
392 let authority_consistent = rederived == row.authority_at_release;
393
394 let transparency_verified = row
398 .transparency
399 .as_ref()
400 .map(|t| verify_inclusion_binds_artifact(&row.artifact_digest, t).is_ok());
401
402 let checkpoint_attested = match (row.transparency.as_ref(), pinned_log_key) {
405 (Some(t), Some(key)) => Some(t.signed_checkpoint.verify_log_signature(key).is_ok()),
406 _ => None,
407 };
408
409 verdicts.push(RowVerdict {
410 artifact_digest: row.artifact_digest.clone(),
411 signer: row.signer.clone(),
412 authority_at_release: row.authority_at_release.clone(),
413 authority_consistent,
414 transparency_verified,
415 checkpoint_attested,
416 });
417 }
418 Ok(verdicts)
419}
420
421#[derive(Serialize)]
425#[serde(tag = "kind", rename_all = "camelCase")]
426enum PackVerdictJson {
427 #[serde(rename = "verdicts")]
429 Verdicts {
430 rows: Vec<RowVerdict>,
432 },
433 #[serde(rename = "error")]
435 Error {
436 code: String,
438 message: String,
440 },
441}
442
443const SERIALIZE_FALLBACK: &str =
445 r#"{"kind":"error","code":"AUTHS-E2301","message":"verdict serialization failed"}"#;
446
447pub fn verify_evidence_pack_offline_json(
465 pack_json: &str,
466 pinned_roots_json: &str,
467 pinned_log_key_hex: Option<&str>,
468) -> String {
469 use auths_crypto::AuthsErrorInfo;
470 let envelope = match verify_pack_json_inner(pack_json, pinned_roots_json, pinned_log_key_hex) {
471 Ok(rows) => PackVerdictJson::Verdicts { rows },
472 Err(e) => PackVerdictJson::Error {
473 code: e.error_code().to_string(),
474 message: e.to_string(),
475 },
476 };
477 serde_json::to_string(&envelope).unwrap_or_else(|_| SERIALIZE_FALLBACK.to_string())
478}
479
480pub fn parse_log_key_hex(hex_key: &str) -> Result<Ed25519PublicKey, EvidencePackError> {
482 let bytes = hex::decode(hex_key.trim())
483 .map_err(|e| EvidencePackError::Decode(format!("pinned log key: invalid hex: {e}")))?;
484 Ed25519PublicKey::try_from_slice(&bytes)
485 .map_err(|e| EvidencePackError::Decode(format!("pinned log key: {e}")))
486}
487
488fn verify_pack_json_inner(
489 pack_json: &str,
490 pinned_roots_json: &str,
491 pinned_log_key_hex: Option<&str>,
492) -> Result<Vec<RowVerdict>, EvidencePackError> {
493 if pack_json.len() > MAX_PACK_JSON_BYTES {
494 return Err(EvidencePackError::Decode(format!(
495 "pack JSON too large: {} bytes, max {}",
496 pack_json.len(),
497 MAX_PACK_JSON_BYTES
498 )));
499 }
500 let pack = EvidencePack::from_json(pack_json)?;
501 let pinned_roots: Vec<IdentityDID> = serde_json::from_str(pinned_roots_json)
502 .map_err(|e| EvidencePackError::Decode(format!("pinned roots: {e}")))?;
503 let pinned_log_key = pinned_log_key_hex.map(parse_log_key_hex).transpose()?;
504 verify_evidence_pack_offline(&pack, &pinned_roots, pinned_log_key.as_ref())
505}
506
507#[cfg(test)]
508#[allow(clippy::unwrap_used)]
509mod tests {
510 use super::*;
511 use crate::core::{Ed25519PublicKey, Ed25519Signature};
512 use crate::tlog::merkle::{hash_children, hash_leaf};
513 use crate::tlog::{Checkpoint, LogOrigin};
514 use auths_keri::witness::independence::EquivocationDetection;
515
516 fn fixed_now() -> DateTime<Utc> {
517 DateTime::parse_from_rfc3339("2026-06-08T00:00:00Z")
518 .unwrap()
519 .with_timezone(&Utc)
520 }
521
522 fn single_operator_ceiling() -> HonestyCeiling {
523 HonestyCeiling {
525 distinct_operators: 1,
526 distinct_organizations: 1,
527 distinct_jurisdictions: 1,
528 distinct_infra_zones: 1,
529 policy_met: false,
530 equivocation: EquivocationDetection::Sampled,
531 shortfalls: vec!["no independent witness commons".into()],
532 label: "single-operator — not yet independent".into(),
533 }
534 }
535
536 fn sample_pack() -> EvidencePack {
537 EvidencePack {
538 schema_version: EVIDENCE_PACK_SCHEMA_VERSION,
539 org: IdentityDID::new_unchecked("did:keri:EOrg"),
540 period: "2026-Q3".into(),
541 framework: ComplianceFramework::Slsa,
542 equivocation_visibility: single_operator_ceiling(),
543 generated_at: fixed_now(),
544 rows: vec![
545 EvidenceRow {
546 artifact_digest: "sha256:aa".into(),
547 signer: IdentityDID::new_unchecked("did:keri:EAlice"),
548 authority_at_release: AuthorityAtSigning::AuthorizedBeforeRevocation,
549 signed_at: Some(7),
550 transparency: None,
551 },
552 EvidenceRow {
553 artifact_digest: "sha256:bb".into(),
554 signer: IdentityDID::new_unchecked("did:keri:EBob"),
555 authority_at_release: AuthorityAtSigning::RejectedRevokedPositionUnknown {
556 revoked_at: 12,
557 },
558 signed_at: None,
559 transparency: None,
560 },
561 ],
562 org_bundle: None,
563 }
564 }
565
566 #[test]
567 fn canonicalize_is_deterministic() {
568 let a = sample_pack().canonicalize().unwrap();
569 let b = sample_pack().canonicalize().unwrap();
570 assert_eq!(
571 a, b,
572 "same inputs must produce byte-identical canonical bytes"
573 );
574 }
575
576 #[test]
577 fn pack_round_trips_through_json() {
578 let pack = sample_pack();
579 let json = pack.canonicalize().unwrap();
580 let back = EvidencePack::from_json(&json).unwrap();
581 assert_eq!(
582 json,
583 back.canonicalize().unwrap(),
584 "canonical JSON must round-trip byte-identically"
585 );
586 }
587
588 #[test]
589 fn position_unknown_is_represented_honestly_not_authorized() {
590 let json = sample_pack().canonicalize().unwrap();
591 assert!(json.contains("rejected_revoked_position_unknown"));
592 let bob_authorized = json.matches("authorized_before_revocation").count();
594 assert_eq!(
595 bob_authorized, 1,
596 "only Alice is authorized-before-revocation"
597 );
598 }
599
600 #[test]
601 fn intoto_statement_carries_subjects_and_predicate_type() {
602 let stmt = sample_pack().to_intoto_statement().unwrap();
603 assert!(stmt.contains("https://in-toto.io/Statement/v1"));
604 assert!(stmt.contains("https://auths.dev/compliance/evidence/v1"));
605 assert!(stmt.contains("\"sha256\":\"aa\""));
606 assert!(stmt.contains("authority_at_signing"));
608 }
609
610 #[test]
611 fn pack_without_bundle_fails_offline_verification_closed() {
612 let pack = sample_pack();
613 let roots = vec![IdentityDID::new_unchecked("did:keri:EOrg")];
614 let err = verify_evidence_pack_offline(&pack, &roots, None).unwrap_err();
615 assert!(err.to_string().contains("no embedded org bundle"));
616 }
617
618 #[test]
619 fn pack_json_contract_reports_errors_as_tagged_envelopes() {
620 let verdict = verify_evidence_pack_offline_json("not json", "[]", None);
621 let v: serde_json::Value = serde_json::from_str(&verdict).unwrap();
622 assert_eq!(v["kind"], "error");
623 assert_eq!(v["code"], "AUTHS-E2302");
624 }
625
626 #[test]
627 fn pack_json_contract_rejects_a_malformed_pinned_log_key() {
628 let pack_json = sample_pack().canonicalize().unwrap();
629 for bad in ["not hex", "abcd"] {
630 let verdict = verify_evidence_pack_offline_json(&pack_json, "[]", Some(bad));
631 let v: serde_json::Value = serde_json::from_str(&verdict).unwrap();
632 assert_eq!(
633 v["kind"], "error",
634 "malformed log key '{bad}' must fail closed"
635 );
636 assert_eq!(v["code"], "AUTHS-E2302");
637 }
638 }
639
640 fn signed_checkpoint_at(size: u64, root: MerkleHash) -> SignedCheckpoint {
641 SignedCheckpoint {
642 checkpoint: Checkpoint {
643 origin: LogOrigin::new("auths.dev/log").unwrap(),
644 size,
645 root,
646 timestamp: fixed_now(),
647 },
648 log_signature: Ed25519Signature::from_bytes([0u8; 64]),
649 log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
650 witnesses: vec![],
651 ecdsa_checkpoint_signature: None,
652 ecdsa_checkpoint_key: None,
653 }
654 }
655
656 #[test]
657 fn transparency_inclusion_against_checkpoint_verifies() {
658 let a = hash_leaf(b"artifact-a");
659 let b = hash_leaf(b"artifact-b");
660 let root = hash_children(&a, &b);
661
662 let t = TransparencyInclusion {
663 leaf_hash: a,
664 inclusion_proof: InclusionProof {
665 index: 0,
666 size: 2,
667 root,
668 hashes: vec![b],
669 },
670 signed_checkpoint: signed_checkpoint_at(2, root),
671 consistency_proof: None,
672 };
673 verify_transparency_inclusion(&t).expect("inclusion against the checkpoint verifies");
674 }
675
676 #[test]
677 fn transparency_inclusion_mismatched_checkpoint_fails() {
678 let a = hash_leaf(b"artifact-a");
679 let b = hash_leaf(b"artifact-b");
680 let root = hash_children(&a, &b);
681
682 let t = TransparencyInclusion {
683 leaf_hash: a,
684 inclusion_proof: InclusionProof {
685 index: 0,
686 size: 2,
687 root,
688 hashes: vec![b],
689 },
690 signed_checkpoint: signed_checkpoint_at(2, MerkleHash::from_bytes([0x99; 32])),
692 consistency_proof: None,
693 };
694 assert!(
695 verify_transparency_inclusion(&t).is_err(),
696 "inclusion not anchored to the checkpoint must fail closed"
697 );
698 }
699
700 fn operator_signed_checkpoint(
702 size: u64,
703 root: MerkleHash,
704 signing_key: &ed25519_dalek::SigningKey,
705 ) -> SignedCheckpoint {
706 use ed25519_dalek::Signer;
707 let checkpoint = Checkpoint {
708 origin: LogOrigin::new("auths.dev/log").unwrap(),
709 size,
710 root,
711 timestamp: fixed_now(),
712 };
713 let sig = signing_key.sign(checkpoint.to_note_body().as_bytes());
714 SignedCheckpoint {
715 checkpoint,
716 log_signature: Ed25519Signature::from_bytes(sig.to_bytes()),
717 log_public_key: Ed25519PublicKey::from_bytes(signing_key.verifying_key().to_bytes()),
718 witnesses: vec![],
719 ecdsa_checkpoint_signature: None,
720 ecdsa_checkpoint_key: None,
721 }
722 }
723
724 fn artifact_evidence(
726 digest: &str,
727 signing_key: &ed25519_dalek::SigningKey,
728 ) -> TransparencyInclusion {
729 let leaf = hash_leaf(digest.as_bytes());
730 let sibling = hash_leaf(b"sha256:other");
731 let root = hash_children(&leaf, &sibling);
732 TransparencyInclusion {
733 leaf_hash: leaf,
734 inclusion_proof: InclusionProof {
735 index: 0,
736 size: 2,
737 root,
738 hashes: vec![sibling],
739 },
740 signed_checkpoint: operator_signed_checkpoint(2, root, signing_key),
741 consistency_proof: None,
742 }
743 }
744
745 #[test]
746 fn artifact_log_inclusion_verifies_under_the_pinned_operator() {
747 let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
748 let digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
749 let evidence = artifact_evidence(digest, &key);
750 let pinned = Ed25519PublicKey::from_bytes(key.verifying_key().to_bytes());
751 verify_artifact_log_inclusion(digest, &evidence, &pinned)
752 .expect("bound, included, operator-attested evidence verifies");
753 }
754
755 #[test]
756 fn artifact_log_inclusion_rejects_evidence_for_a_different_artifact() {
757 let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
758 let digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
759 let evidence = artifact_evidence(digest, &key);
760 let pinned = Ed25519PublicKey::from_bytes(key.verifying_key().to_bytes());
761 let other = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
762 let err = verify_artifact_log_inclusion(other, &evidence, &pinned)
763 .expect_err("a valid proof for some OTHER leaf is a mismatch, not evidence");
764 assert!(
765 err.to_string().contains("not for this artifact"),
766 "rejection must name the binding failure, got: {err}"
767 );
768 }
769
770 #[test]
771 fn artifact_log_inclusion_rejects_a_different_pinned_operator() {
772 let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
773 let digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
774 let evidence = artifact_evidence(digest, &key);
775 let other_operator = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]);
776 let pinned = Ed25519PublicKey::from_bytes(other_operator.verifying_key().to_bytes());
777 let err = verify_artifact_log_inclusion(digest, &evidence, &pinned)
778 .expect_err("a checkpoint from a different operator must fail the pinned-key check");
779 assert!(
780 err.to_string().contains("pinned log key"),
781 "rejection must name the operator failure, got: {err}"
782 );
783 }
784}