Skip to main content

auths_sdk/domains/compliance/
query.rs

1//! Compliance-as-a-query: turn the org's append-only history into a
2//! deterministic, offline-verifiable evidence pack.
3//!
4//! Each row answers "who signed this artifact, and were they authorized **at
5//! release time**?" — using [`classify_authority_at_signing`], ordered by KEL
6//! position, never wall-clock. The pack embeds the honest witness-diversity
7//! verdict ([`HonestyCeiling`] via [`ceiling_for_policy_load`]) rather than a
8//! bare non-equivocation flag, so it can never over-claim third-party
9//! non-equivocation while only self-run witnesses exist.
10//!
11//! The pack is canonicalized with `json-canon` so two runs over the same inputs
12//! produce byte-identical output (an auditor can re-derive it). An **offline**
13//! pack ([`build_offline_evidence_pack`]) additionally embeds the org's KEL
14//! material as an [`AirGappedOrgBundle`] plus, per row, the transparency-log
15//! inclusion (and consistency) proof, so each row verifies with **zero network**
16//! via [`verify_evidence_pack_offline`]. The org DSSE signature over the in-toto
17//! statement lives in [`crate::domains::compliance::dsse`].
18
19use 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
36/// The current evidence-pack schema version.
37pub const EVIDENCE_PACK_SCHEMA_VERSION: u32 = 1;
38
39/// A typed failure building or verifying a compliance evidence pack.
40#[derive(Debug, thiserror::Error)]
41#[non_exhaustive]
42pub enum ComplianceQueryError {
43    /// Authority classification against the org KEL failed.
44    #[error("authority classification failed: {0}")]
45    Authority(#[from] OrgError),
46    /// Canonical serialization (`json-canon`) failed.
47    #[error("canonicalization failed: {0}")]
48    Canonicalize(String),
49    /// Org DSSE signing failed.
50    #[error("org signing failed: {0}")]
51    Signing(String),
52    /// A base64/hex decode failed while reading a signed/wrapped pack.
53    #[error("decode failed: {0}")]
54    Decode(String),
55    /// A DSSE signature did not verify against the org key.
56    #[error("verification failed: {0}")]
57    Verification(String),
58    /// Offline pack verification failed (tampered KEL, unpinned root, or a
59    /// transparency proof that did not check out).
60    #[error("offline verification failed: {0}")]
61    OfflineVerification(String),
62}
63
64/// The compliance framework a report targets. Predicate rendering (SLSA
65/// provenance / SPDX SBOM / CRA mapping) lands in fn-157.9; here it only tags
66/// the pack.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum ComplianceFramework {
70    /// SLSA provenance.
71    Slsa,
72    /// SPDX software bill of materials.
73    Sbom,
74    /// EU Cyber Resilience Act obligation mapping.
75    Cra,
76}
77
78/// The transparency-log evidence that proves an artifact's entry is in the log,
79/// bundled so a row verifies offline.
80///
81/// `inclusion_proof` proves the `leaf_hash` is in the tree at size N (root
82/// `inclusion_proof.root`). When the inclusion was taken at a tree size **older**
83/// than the embedded `signed_checkpoint`, a `consistency_proof` (N→M) proves the
84/// older root is a prefix of the checkpoint root. With no consistency proof the
85/// inclusion must be **against** the checkpoint (same root and size).
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct TransparencyInclusion {
88    /// The artifact's transparency-log leaf hash (the value `inclusion_proof` proves).
89    pub leaf_hash: MerkleHash,
90    /// Inclusion proof for `leaf_hash` at tree size `inclusion_proof.size`.
91    pub inclusion_proof: InclusionProof,
92    /// The signed checkpoint the inclusion is anchored to (directly, or via the
93    /// consistency proof). Its signature trust requires a **pinned log key**
94    /// ([`auths_transparency::verify_checkpoint_signature`]) — a separate axis from
95    /// this offline Merkle check.
96    pub signed_checkpoint: SignedCheckpoint,
97    /// Consistency proof from the inclusion's tree size to the checkpoint's, present
98    /// only when the inclusion was taken at an earlier size than the checkpoint.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub consistency_proof: Option<ConsistencyProof>,
101}
102
103/// A release to classify: an artifact digest, the member that signed it, and its
104/// in-band signing KEL position (`None` when the artifact carries no position —
105/// which the classifier conservatively rejects).
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct ReleaseRecord {
108    /// The artifact content digest (e.g. `sha256:<hex>`).
109    pub artifact_digest: String,
110    /// The signing member's KEL prefix.
111    pub signer_prefix: Prefix,
112    /// The artifact's in-band signing position (`Auths-Anchor-Seq`), if any.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub signed_at: Option<u128>,
115    /// Optional transparency-log inclusion evidence so the row verifies offline.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub transparency: Option<TransparencyInclusion>,
118}
119
120/// One row of compliance evidence: the signer's authority **at release**.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct EvidenceRow {
123    /// The artifact content digest.
124    pub artifact_digest: String,
125    /// The signing member's self-certifying identity.
126    pub signer: IdentityDID,
127    /// The signer's authority at the signing position, by KEL order.
128    pub authority_at_release: AuthorityAtSigning,
129    /// The artifact's in-band signing position, if any.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub signed_at: Option<u128>,
132    /// Transparency-log inclusion evidence, when supplied — lets the row prove the
133    /// artifact's log membership offline.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub transparency: Option<TransparencyInclusion>,
136}
137
138/// A deterministic, offline-verifiable compliance evidence pack.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct EvidencePack {
141    /// Schema version.
142    pub schema_version: u32,
143    /// The org whose history this pack covers.
144    pub org: IdentityDID,
145    /// The reporting period (free-form, e.g. `2026-Q3`).
146    pub period: String,
147    /// The framework this pack targets.
148    pub framework: ComplianceFramework,
149    /// The honest witness-diversity verdict — NEVER a bare `non_equivocation`
150    /// flag. With only self-run/placeholder witnesses this is `policy_met ==
151    /// false` ("single-operator — not yet independent").
152    pub equivocation_visibility: HonestyCeiling,
153    /// When the pack was generated (injected clock; never `Utc::now()` in domain
154    /// code). Two runs with the same inputs and timestamp are byte-identical.
155    pub generated_at: DateTime<Utc>,
156    /// One row per classified release.
157    pub rows: Vec<EvidenceRow>,
158    /// The embedded, URL-free KEL material (org + member KELs + off-boarding
159    /// records + pinned root) that makes authority re-derivable offline. `None`
160    /// for a non-offline pack ([`build_evidence_pack`]).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub org_bundle: Option<AirGappedOrgBundle>,
163}
164
165impl EvidencePack {
166    /// Canonicalize with `json-canon` — the byte-exact, reproducible form an
167    /// auditor re-derives and the org signs.
168    pub fn canonicalize(&self) -> Result<String, ComplianceQueryError> {
169        json_canon::to_string(self).map_err(|e| ComplianceQueryError::Canonicalize(e.to_string()))
170    }
171
172    /// Parse a pack back from its canonical JSON form. Typed identifiers fail
173    /// closed on malformed input.
174    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    /// Render as a canonical in-toto Statement: `subject` = the artifact digests,
179    /// `predicate` = this pack. The org DSSE signature over these bytes lives in
180    /// [`crate::domains::compliance::dsse::sign_evidence_pack`]; the SLSA/SBOM/CRA
181    /// predicate variants land in fn-157.9.
182    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
210/// Load the pinned witness-diversity policy, **failing closed**.
211///
212/// Mirrors the monitor/cosigner: with no pinned policy path (the reality until an
213/// independent commons is admitted) this returns `Err`, which
214/// [`ceiling_for_policy_load`] renders as the honest "single-operator — not yet
215/// independent" verdict. A surface NEVER falls back to an unconstrained policy that
216/// would let it imply independence.
217///
218/// Args:
219/// * `path`: The pinned `witness_policy.json` path, or `None` when unset.
220///
221/// Usage:
222/// ```ignore
223/// let policy = load_witness_policy(path.as_deref());
224/// let pack = build_evidence_pack(&ctx, org, &p, "2026-Q3", fw, &rel, &policy, now)?;
225/// ```
226pub 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
235/// Classify each release into an [`EvidenceRow`], by KEL order.
236fn 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/// Build a compliance evidence pack by classifying each release against the org
258/// KEL.
259///
260/// The releases are the query input — the caller (CLI / a higher layer) supplies
261/// the artifacts released in the period; this engine classifies each signer's
262/// authority at the release position and embeds the honest witness verdict. It
263/// performs no I/O beyond the registry reads `classify_authority_at_signing`
264/// already does, and embeds no offline bundle (use [`build_offline_evidence_pack`]
265/// for a URL-free pack).
266///
267/// Args:
268/// * `ctx`: Auths context (registry).
269/// * `org`: The org's self-certifying identity (for the pack header).
270/// * `org_prefix`: The org's KEL prefix (the delegator).
271/// * `period`: The reporting period label.
272/// * `framework`: The target compliance framework.
273/// * `releases`: The artifacts released in the period.
274/// * `witness_policy`: The witness-policy load result (drives the honest verdict).
275/// * `generated_at`: Injected generation timestamp.
276///
277/// Usage:
278/// ```ignore
279/// let pack = build_evidence_pack(&ctx, org, &org_prefix, "2026-Q3",
280///     ComplianceFramework::Slsa, &releases, &policy_result, now)?;
281/// let canonical = pack.canonicalize()?;
282/// ```
283#[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/// Build an **offline-verifiable** compliance evidence pack.
308///
309/// Identical to [`build_evidence_pack`] but embeds the org's KEL material as an
310/// [`AirGappedOrgBundle`] (org KEL + every member KEL + off-boarding records +
311/// pinned root), so [`verify_evidence_pack_offline`] can re-derive every row's
312/// authority and check each row's transparency proof with **zero network**.
313///
314/// Args:
315/// * `ctx`: Auths context (registry, clock — used to build the embedded bundle).
316/// * `org`: The org's self-certifying identity (for the pack header).
317/// * `org_prefix`: The org's KEL prefix (the delegator).
318/// * `period`: The reporting period label.
319/// * `framework`: The target compliance framework.
320/// * `releases`: The artifacts released in the period.
321/// * `witness_policy`: The witness-policy load result (drives the honest verdict).
322/// * `generated_at`: Injected generation timestamp.
323///
324/// Usage:
325/// ```ignore
326/// let pack = build_offline_evidence_pack(&ctx, org, &org_prefix, "2026-Q3",
327///     ComplianceFramework::Slsa, &releases, &policy_result, now)?;
328/// std::fs::write("acme-2026Q3.evidence", pack.canonicalize()?)?;
329/// ```
330#[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/// The offline-verification verdict for one evidence row.
356#[derive(Debug, Clone, PartialEq, Serialize)]
357pub struct RowVerdict {
358    /// The artifact this row covers.
359    pub artifact_digest: String,
360    /// The row's signer.
361    pub signer: IdentityDID,
362    /// The authority recorded in the row.
363    pub authority_at_release: AuthorityAtSigning,
364    /// Whether re-deriving authority from the embedded KEL matches the row's
365    /// recorded verdict (a tampered row flips this to `false`).
366    pub authority_consistent: bool,
367    /// Whether the row's transparency inclusion/consistency proof verified, or
368    /// `None` when the row carries no transparency evidence.
369    pub transparency_verified: Option<bool>,
370}
371
372/// Verify the transparency inclusion (and consistency) of one row, offline.
373fn 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
412/// Verify an offline evidence pack with **zero network**.
413///
414/// Checks the embedded [`AirGappedOrgBundle`] integrity (every event self-addresses),
415/// confirms the org is a pinned root, flags KEL duplicity, then for each row
416/// re-derives authority-at-release from the embedded KEL (tamper check) and verifies
417/// any transparency inclusion/consistency proof. The checkpoint **signature** trust
418/// (that the log operator signed the root) is a separate axis requiring a pinned log
419/// key ([`auths_transparency::verify_checkpoint_signature`]); this function proves
420/// the Merkle membership, not the log operator's identity.
421///
422/// Args:
423/// * `pack`: The pack to verify (must have been built by [`build_offline_evidence_pack`]).
424/// * `pinned_roots`: The verifier's pinned trust roots.
425///
426/// Usage:
427/// ```ignore
428/// let verdicts = verify_evidence_pack_offline(&pack, &roots)?;
429/// assert!(verdicts.iter().all(|v| v.authority_consistent));
430/// ```
431pub 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        // The reality today: no independent commons → not policy_met.
497        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        // Honest: renders the ceiling, never a `non_equivocation: true`.
560        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        // The unclassifiable row must NOT be silently rendered as authorized.
569        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        // The predicate carries the authority verdicts.
583        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            // A checkpoint over a DIFFERENT root with no consistency proof must fail.
641            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}