1use std::path::Path;
20
21use auths_id::keri::types::Prefix;
22use auths_transparency::{
23 ConsistencyProof, HonestyCeiling, InclusionProof, MerkleHash, SignedCheckpoint, WitnessPolicy,
24 WitnessPolicyError, ceiling_for_policy_load,
25};
26use auths_verifier::IdentityDID;
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29
30use crate::context::AuthsContext;
31use crate::domains::org::audit::{AuthorityAtSigning, classify_authority_at_signing};
32use crate::domains::org::bundle::{AirGappedOrgBundle, build_org_bundle};
33use crate::domains::org::error::OrgError;
34use crate::domains::org::offline_verify::{classify_authority_in_bundle, verify_org_bundle};
35
36pub const EVIDENCE_PACK_SCHEMA_VERSION: u32 = 1;
38
39#[derive(Debug, thiserror::Error)]
41#[non_exhaustive]
42pub enum ComplianceQueryError {
43 #[error("authority classification failed: {0}")]
45 Authority(#[from] OrgError),
46 #[error("canonicalization failed: {0}")]
48 Canonicalize(String),
49 #[error("org signing failed: {0}")]
51 Signing(String),
52 #[error("decode failed: {0}")]
54 Decode(String),
55 #[error("verification failed: {0}")]
57 Verification(String),
58 #[error("offline verification failed: {0}")]
61 OfflineVerification(String),
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum ComplianceFramework {
70 Slsa,
72 Sbom,
74 Cra,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct TransparencyInclusion {
88 pub leaf_hash: MerkleHash,
90 pub inclusion_proof: InclusionProof,
92 pub signed_checkpoint: SignedCheckpoint,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub consistency_proof: Option<ConsistencyProof>,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct ReleaseRecord {
108 pub artifact_digest: String,
110 pub signer_prefix: Prefix,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub signed_at: Option<u128>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub transparency: Option<TransparencyInclusion>,
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct EvidenceRow {
123 pub artifact_digest: String,
125 pub signer: IdentityDID,
127 pub authority_at_release: AuthorityAtSigning,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub signed_at: Option<u128>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub transparency: Option<TransparencyInclusion>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct EvidencePack {
141 pub schema_version: u32,
143 pub org: IdentityDID,
145 pub period: String,
147 pub framework: ComplianceFramework,
149 pub equivocation_visibility: HonestyCeiling,
153 pub generated_at: DateTime<Utc>,
156 pub rows: Vec<EvidenceRow>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub org_bundle: Option<AirGappedOrgBundle>,
163}
164
165impl EvidencePack {
166 pub fn canonicalize(&self) -> Result<String, ComplianceQueryError> {
169 json_canon::to_string(self).map_err(|e| ComplianceQueryError::Canonicalize(e.to_string()))
170 }
171
172 pub fn from_json(json: &str) -> Result<Self, ComplianceQueryError> {
175 serde_json::from_str(json).map_err(|e| ComplianceQueryError::Decode(e.to_string()))
176 }
177
178 pub fn to_intoto_statement(&self) -> Result<String, ComplianceQueryError> {
183 let subject: Vec<serde_json::Value> = self
184 .rows
185 .iter()
186 .map(|r| {
187 let digest = r
188 .artifact_digest
189 .strip_prefix("sha256:")
190 .unwrap_or(&r.artifact_digest);
191 serde_json::json!({
192 "name": r.artifact_digest,
193 "digest": { "sha256": digest },
194 })
195 })
196 .collect();
197
198 let statement = serde_json::json!({
199 "_type": "https://in-toto.io/Statement/v1",
200 "subject": subject,
201 "predicateType": "https://auths.dev/compliance/evidence/v1",
202 "predicate": self,
203 });
204
205 json_canon::to_string(&statement)
206 .map_err(|e| ComplianceQueryError::Canonicalize(e.to_string()))
207 }
208}
209
210pub fn load_witness_policy(path: Option<&Path>) -> Result<WitnessPolicy, WitnessPolicyError> {
227 match path {
228 Some(p) => WitnessPolicy::load(p),
229 None => Err(WitnessPolicyError::NotFound {
230 path: "<AUTHS_WITNESS_POLICY_PATH unset>".into(),
231 }),
232 }
233}
234
235fn classify_rows(
237 ctx: &AuthsContext,
238 org_prefix: &Prefix,
239 releases: &[ReleaseRecord],
240) -> Result<Vec<EvidenceRow>, ComplianceQueryError> {
241 let mut rows = Vec::with_capacity(releases.len());
242 for r in releases {
243 let authority_at_release =
244 classify_authority_at_signing(ctx, org_prefix, &r.signer_prefix, r.signed_at)?;
245 let signer = IdentityDID::new_unchecked(format!("did:keri:{}", r.signer_prefix.as_str()));
246 rows.push(EvidenceRow {
247 artifact_digest: r.artifact_digest.clone(),
248 signer,
249 authority_at_release,
250 signed_at: r.signed_at,
251 transparency: r.transparency.clone(),
252 });
253 }
254 Ok(rows)
255}
256
257#[allow(clippy::too_many_arguments)]
284pub fn build_evidence_pack(
285 ctx: &AuthsContext,
286 org: IdentityDID,
287 org_prefix: &Prefix,
288 period: impl Into<String>,
289 framework: ComplianceFramework,
290 releases: &[ReleaseRecord],
291 witness_policy: &Result<WitnessPolicy, WitnessPolicyError>,
292 generated_at: DateTime<Utc>,
293) -> Result<EvidencePack, ComplianceQueryError> {
294 let rows = classify_rows(ctx, org_prefix, releases)?;
295 Ok(EvidencePack {
296 schema_version: EVIDENCE_PACK_SCHEMA_VERSION,
297 org,
298 period: period.into(),
299 framework,
300 equivocation_visibility: ceiling_for_policy_load(witness_policy),
301 generated_at,
302 rows,
303 org_bundle: None,
304 })
305}
306
307#[allow(clippy::too_many_arguments)]
331pub fn build_offline_evidence_pack(
332 ctx: &AuthsContext,
333 org: IdentityDID,
334 org_prefix: &Prefix,
335 period: impl Into<String>,
336 framework: ComplianceFramework,
337 releases: &[ReleaseRecord],
338 witness_policy: &Result<WitnessPolicy, WitnessPolicyError>,
339 generated_at: DateTime<Utc>,
340) -> Result<EvidencePack, ComplianceQueryError> {
341 let mut pack = build_evidence_pack(
342 ctx,
343 org,
344 org_prefix,
345 period,
346 framework,
347 releases,
348 witness_policy,
349 generated_at,
350 )?;
351 pack.org_bundle = Some(build_org_bundle(ctx, org_prefix)?);
352 Ok(pack)
353}
354
355#[derive(Debug, Clone, PartialEq, Serialize)]
357pub struct RowVerdict {
358 pub artifact_digest: String,
360 pub signer: IdentityDID,
362 pub authority_at_release: AuthorityAtSigning,
364 pub authority_consistent: bool,
367 pub transparency_verified: Option<bool>,
370}
371
372fn verify_transparency_inclusion(t: &TransparencyInclusion) -> Result<(), ComplianceQueryError> {
374 t.inclusion_proof.verify(&t.leaf_hash).map_err(|e| {
375 ComplianceQueryError::OfflineVerification(format!("inclusion proof did not verify: {e}"))
376 })?;
377
378 let checkpoint_root = t.signed_checkpoint.checkpoint.root;
379 let checkpoint_size = t.signed_checkpoint.checkpoint.size;
380
381 match &t.consistency_proof {
382 Some(c) => {
383 if c.old_root != t.inclusion_proof.root || c.old_size != t.inclusion_proof.size {
384 return Err(ComplianceQueryError::OfflineVerification(
385 "consistency proof old root/size does not match the inclusion proof".into(),
386 ));
387 }
388 if c.new_root != checkpoint_root || c.new_size != checkpoint_size {
389 return Err(ComplianceQueryError::OfflineVerification(
390 "consistency proof new root/size does not match the signed checkpoint".into(),
391 ));
392 }
393 c.verify().map_err(|e| {
394 ComplianceQueryError::OfflineVerification(format!(
395 "consistency proof did not verify: {e}"
396 ))
397 })?;
398 }
399 None => {
400 if t.inclusion_proof.root != checkpoint_root
401 || t.inclusion_proof.size != checkpoint_size
402 {
403 return Err(ComplianceQueryError::OfflineVerification(
404 "inclusion proof is not against the signed checkpoint and no consistency proof was provided".into(),
405 ));
406 }
407 }
408 }
409 Ok(())
410}
411
412pub fn verify_evidence_pack_offline(
432 pack: &EvidencePack,
433 pinned_roots: &[IdentityDID],
434) -> Result<Vec<RowVerdict>, ComplianceQueryError> {
435 let bundle = pack.org_bundle.as_ref().ok_or_else(|| {
436 ComplianceQueryError::OfflineVerification(
437 "pack carries no embedded org bundle — not an offline-verifiable pack".into(),
438 )
439 })?;
440
441 let report = verify_org_bundle(bundle, pinned_roots, None)
442 .map_err(|e| ComplianceQueryError::OfflineVerification(e.to_string()))?;
443 if !report.root_pinned {
444 return Err(ComplianceQueryError::OfflineVerification(format!(
445 "org {} is not in the pinned trust roots",
446 bundle.org_did.as_str()
447 )));
448 }
449 if report.duplicity_detected {
450 return Err(ComplianceQueryError::OfflineVerification(
451 "org KEL shows duplicity (same-seq divergent SAIDs)".into(),
452 ));
453 }
454
455 let mut verdicts = Vec::with_capacity(pack.rows.len());
456 for row in &pack.rows {
457 let signer_prefix = Prefix::new_unchecked(
458 row.signer
459 .as_str()
460 .strip_prefix("did:keri:")
461 .unwrap_or(row.signer.as_str())
462 .to_string(),
463 );
464 let rederived = classify_authority_in_bundle(bundle, &signer_prefix, row.signed_at);
465 let authority_consistent = rederived == row.authority_at_release;
466
467 let transparency_verified = row
468 .transparency
469 .as_ref()
470 .map(|t| verify_transparency_inclusion(t).is_ok());
471
472 verdicts.push(RowVerdict {
473 artifact_digest: row.artifact_digest.clone(),
474 signer: row.signer.clone(),
475 authority_at_release: row.authority_at_release.clone(),
476 authority_consistent,
477 transparency_verified,
478 });
479 }
480 Ok(verdicts)
481}
482
483#[cfg(test)]
484#[allow(clippy::unwrap_used)]
485mod tests {
486 use super::*;
487 use auths_transparency::types::LogOrigin;
488
489 fn fixed_now() -> DateTime<Utc> {
490 DateTime::parse_from_rfc3339("2026-06-08T00:00:00Z")
491 .unwrap()
492 .with_timezone(&Utc)
493 }
494
495 fn single_operator_ceiling() -> HonestyCeiling {
496 ceiling_for_policy_load(&Err(WitnessPolicyError::NotFound {
498 path: "<unset>".into(),
499 }))
500 }
501
502 fn sample_pack() -> EvidencePack {
503 EvidencePack {
504 schema_version: EVIDENCE_PACK_SCHEMA_VERSION,
505 org: IdentityDID::new_unchecked("did:keri:EOrg"),
506 period: "2026-Q3".into(),
507 framework: ComplianceFramework::Slsa,
508 equivocation_visibility: single_operator_ceiling(),
509 generated_at: fixed_now(),
510 rows: vec![
511 EvidenceRow {
512 artifact_digest: "sha256:aa".into(),
513 signer: IdentityDID::new_unchecked("did:keri:EAlice"),
514 authority_at_release: AuthorityAtSigning::AuthorizedBeforeRevocation,
515 signed_at: Some(7),
516 transparency: None,
517 },
518 EvidenceRow {
519 artifact_digest: "sha256:bb".into(),
520 signer: IdentityDID::new_unchecked("did:keri:EBob"),
521 authority_at_release: AuthorityAtSigning::RejectedRevokedPositionUnknown {
522 revoked_at: 12,
523 },
524 signed_at: None,
525 transparency: None,
526 },
527 ],
528 org_bundle: None,
529 }
530 }
531
532 #[test]
533 fn canonicalize_is_deterministic() {
534 let a = sample_pack().canonicalize().unwrap();
535 let b = sample_pack().canonicalize().unwrap();
536 assert_eq!(
537 a, b,
538 "same inputs must produce byte-identical canonical bytes"
539 );
540 }
541
542 #[test]
543 fn pack_round_trips_through_json() {
544 let pack = sample_pack();
545 let json = pack.canonicalize().unwrap();
546 let back = EvidencePack::from_json(&json).unwrap();
547 assert_eq!(
548 json,
549 back.canonicalize().unwrap(),
550 "canonical JSON must round-trip byte-identically"
551 );
552 }
553
554 #[test]
555 fn pack_embeds_single_operator_ceiling_not_a_bare_flag() {
556 let pack = sample_pack();
557 assert!(!pack.equivocation_visibility.policy_met);
558 let json = pack.canonicalize().unwrap();
559 assert!(!json.contains("non_equivocation"));
561 assert!(json.contains("not yet independent"));
562 }
563
564 #[test]
565 fn position_unknown_is_represented_honestly_not_authorized() {
566 let json = sample_pack().canonicalize().unwrap();
567 assert!(json.contains("rejected_revoked_position_unknown"));
568 let bob_authorized = json.matches("authorized_before_revocation").count();
570 assert_eq!(
571 bob_authorized, 1,
572 "only Alice is authorized-before-revocation"
573 );
574 }
575
576 #[test]
577 fn intoto_statement_carries_subjects_and_predicate_type() {
578 let stmt = sample_pack().to_intoto_statement().unwrap();
579 assert!(stmt.contains("https://in-toto.io/Statement/v1"));
580 assert!(stmt.contains("https://auths.dev/compliance/evidence/v1"));
581 assert!(stmt.contains("\"sha256\":\"aa\""));
582 assert!(stmt.contains("authority_at_signing"));
584 }
585
586 fn signed_checkpoint_at(size: u64, root: MerkleHash) -> SignedCheckpoint {
587 use auths_transparency::checkpoint::Checkpoint;
588 use auths_verifier::{Ed25519PublicKey, Ed25519Signature};
589 SignedCheckpoint {
590 checkpoint: Checkpoint {
591 origin: LogOrigin::new("auths.dev/log").unwrap(),
592 size,
593 root,
594 timestamp: fixed_now(),
595 },
596 log_signature: Ed25519Signature::from_bytes([0u8; 64]),
597 log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
598 witnesses: vec![],
599 ecdsa_checkpoint_signature: None,
600 ecdsa_checkpoint_key: None,
601 }
602 }
603
604 #[test]
605 fn transparency_inclusion_against_checkpoint_verifies() {
606 use auths_transparency::merkle::{hash_children, hash_leaf};
607 let a = hash_leaf(b"artifact-a");
608 let b = hash_leaf(b"artifact-b");
609 let root = hash_children(&a, &b);
610
611 let t = TransparencyInclusion {
612 leaf_hash: a,
613 inclusion_proof: InclusionProof {
614 index: 0,
615 size: 2,
616 root,
617 hashes: vec![b],
618 },
619 signed_checkpoint: signed_checkpoint_at(2, root),
620 consistency_proof: None,
621 };
622 verify_transparency_inclusion(&t).expect("inclusion against the checkpoint verifies");
623 }
624
625 #[test]
626 fn transparency_inclusion_mismatched_checkpoint_fails() {
627 use auths_transparency::merkle::{hash_children, hash_leaf};
628 let a = hash_leaf(b"artifact-a");
629 let b = hash_leaf(b"artifact-b");
630 let root = hash_children(&a, &b);
631
632 let t = TransparencyInclusion {
633 leaf_hash: a,
634 inclusion_proof: InclusionProof {
635 index: 0,
636 size: 2,
637 root,
638 hashes: vec![b],
639 },
640 signed_checkpoint: signed_checkpoint_at(2, MerkleHash::from_bytes([0x99; 32])),
642 consistency_proof: None,
643 };
644 assert!(
645 verify_transparency_inclusion(&t).is_err(),
646 "inclusion not anchored to the checkpoint must fail closed"
647 );
648 }
649}