Skip to main content

auths_verifier/
evidence_pack.rs

1//! Offline verification of a compliance evidence pack — zero network, pure
2//! function of the pack's bytes.
3//!
4//! An evidence pack is the org's append-only history rendered as a
5//! deterministic, offline-verifiable document: one row per release, each
6//! answering "who signed this artifact, and were they authorized **at
7//! release time**?" by KEL position, never wall-clock. An **offline** pack
8//! embeds the org's KEL material as an
9//! [`AirGappedOrgBundle`](crate::org_bundle::AirGappedOrgBundle) plus, per
10//! row, the transparency-log inclusion (and consistency) proof.
11//!
12//! This module owns the pack's wire types and [`verify_evidence_pack_offline`]
13//! — the verification half. The *build* half (which classifies releases
14//! against a live registry) lives in `auths-sdk` and re-exports these types,
15//! so a CI gate, audit tool, or **browser** (via the WASM exports) replays
16//! the same verdict from the pack file alone.
17
18use 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
30/// The current evidence-pack schema version.
31pub const EVIDENCE_PACK_SCHEMA_VERSION: u32 = 1;
32
33/// Maximum accepted JSON input for the JSON/WASM surface (16 MiB) — a pack
34/// embeds whole KELs and Merkle proofs, so the ceiling matches the org-bundle
35/// contract's.
36pub const MAX_PACK_JSON_BYTES: usize = 16 * 1024 * 1024;
37
38/// A typed failure parsing or verifying a compliance evidence pack.
39#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum EvidencePackError {
42    /// Canonical serialization (`json-canon`) failed.
43    #[error("canonicalization failed: {0}")]
44    Canonicalize(String),
45    /// The pack (or a JSON input) could not be decoded.
46    #[error("decode failed: {0}")]
47    Decode(String),
48    /// Offline pack verification failed (tampered KEL, unpinned root, or a
49    /// transparency proof that did not check out).
50    #[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/// The compliance framework a report targets. Each variant selects the
76/// predicate the report builder renders (SLSA provenance + VSA / SPDX SBOM /
77/// CRA→SSDF / SOC 2 TSC / ISO 27001 Annex-A); here it only tags the pack.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum ComplianceFramework {
81    /// SLSA provenance.
82    Slsa,
83    /// SPDX software bill of materials.
84    Sbom,
85    /// EU Cyber Resilience Act obligation mapping.
86    Cra,
87    /// SOC 2 Trust Services Criteria (TSC) control mapping.
88    Soc2,
89    /// ISO/IEC 27001:2022 Annex-A control mapping.
90    Iso27001,
91}
92
93/// The transparency-log evidence that proves an artifact's entry is in the log,
94/// bundled so a row verifies offline.
95///
96/// `inclusion_proof` proves the `leaf_hash` is in the tree at size N (root
97/// `inclusion_proof.root`). When the inclusion was taken at a tree size **older**
98/// than the embedded `signed_checkpoint`, a `consistency_proof` (N→M) proves the
99/// older root is a prefix of the checkpoint root. With no consistency proof the
100/// inclusion must be **against** the checkpoint (same root and size).
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct TransparencyInclusion {
103    /// The artifact's transparency-log leaf hash (the value `inclusion_proof`
104    /// proves). For a release row the leaf data is the canonical artifact
105    /// digest string (`sha256:<hex>`), so `leaf_hash =
106    /// hash_leaf(artifact_digest)` — row verification re-derives and compares
107    /// it, binding the proof to *this* artifact rather than to whatever leaf
108    /// the prover chose to embed.
109    pub leaf_hash: MerkleHash,
110    /// Inclusion proof for `leaf_hash` at tree size `inclusion_proof.size`.
111    pub inclusion_proof: InclusionProof,
112    /// The signed checkpoint the inclusion is anchored to (directly, or via the
113    /// consistency proof). Its signature is verified against the verifier's
114    /// **pinned log key** when one is supplied to
115    /// [`verify_evidence_pack_offline`] — upgrading "in the log" from bare
116    /// Merkle membership to operator-attested non-repudiation.
117    pub signed_checkpoint: SignedCheckpoint,
118    /// Consistency proof from the inclusion's tree size to the checkpoint's, present
119    /// only when the inclusion was taken at an earlier size than the checkpoint.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub consistency_proof: Option<ConsistencyProof>,
122}
123
124/// One row of compliance evidence: the signer's authority **at release**.
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126pub struct EvidenceRow {
127    /// The artifact content digest.
128    pub artifact_digest: String,
129    /// The signing member's self-certifying identity.
130    pub signer: IdentityDID,
131    /// The signer's authority at the signing position, by KEL order.
132    pub authority_at_release: AuthorityAtSigning,
133    /// The artifact's in-band signing position, if any.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub signed_at: Option<u128>,
136    /// Transparency-log inclusion evidence, when supplied — lets the row prove the
137    /// artifact's log membership offline.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub transparency: Option<TransparencyInclusion>,
140}
141
142/// A deterministic, offline-verifiable compliance evidence pack.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct EvidencePack {
145    /// Schema version.
146    pub schema_version: u32,
147    /// The org whose history this pack covers.
148    pub org: IdentityDID,
149    /// The reporting period (free-form, e.g. `2026-Q3`).
150    pub period: String,
151    /// The framework this pack targets.
152    pub framework: ComplianceFramework,
153    /// The honest witness-diversity verdict — NEVER a bare `non_equivocation`
154    /// flag. With only self-run/placeholder witnesses this is `policy_met ==
155    /// false` ("single-operator — not yet independent").
156    pub equivocation_visibility: HonestyCeiling,
157    /// When the pack was generated (injected clock; never `Utc::now()` in domain
158    /// code). Two runs with the same inputs and timestamp are byte-identical.
159    pub generated_at: DateTime<Utc>,
160    /// One row per classified release.
161    pub rows: Vec<EvidenceRow>,
162    /// The embedded, URL-free KEL material (org + member KELs + off-boarding
163    /// records + pinned root) that makes authority re-derivable offline. `None`
164    /// for a non-offline pack.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub org_bundle: Option<AirGappedOrgBundle>,
167}
168
169impl EvidencePack {
170    /// Canonicalize with `json-canon` — the byte-exact, reproducible form an
171    /// auditor re-derives and the org signs.
172    pub fn canonicalize(&self) -> Result<String, EvidencePackError> {
173        json_canon::to_string(self).map_err(|e| EvidencePackError::Canonicalize(e.to_string()))
174    }
175
176    /// Parse a pack back from its canonical JSON form. Typed identifiers fail
177    /// closed on malformed input.
178    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    /// Render as a canonical in-toto Statement: `subject` = the artifact digests,
183    /// `predicate` = this pack. The org DSSE signature over these bytes lives in
184    /// the SDK's compliance DSSE module.
185    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/// The offline-verification verdict for one evidence row.
214#[derive(Debug, Clone, PartialEq, Serialize)]
215pub struct RowVerdict {
216    /// The artifact this row covers.
217    pub artifact_digest: String,
218    /// The row's signer.
219    pub signer: IdentityDID,
220    /// The authority recorded in the row.
221    pub authority_at_release: AuthorityAtSigning,
222    /// Whether re-deriving authority from the embedded KEL matches the row's
223    /// recorded verdict (a tampered row flips this to `false`).
224    pub authority_consistent: bool,
225    /// Whether the row's transparency evidence verified: the leaf hash
226    /// re-derives from the row's artifact digest AND the inclusion/consistency
227    /// proof checks out. `None` when the row carries no transparency evidence.
228    pub transparency_verified: Option<bool>,
229    /// Whether the row's signed checkpoint is **attested by the pinned log
230    /// operator**: `Some(true)` when the checkpoint signature verified under
231    /// the caller's pinned log key, `Some(false)` when it did not (a forged
232    /// checkpoint, or one signed by a different operator). `None` when the
233    /// caller pinned no log key or the row carries no transparency evidence —
234    /// the verdict is then Merkle membership only, and says so.
235    pub checkpoint_attested: Option<bool>,
236}
237
238/// Verify the transparency inclusion (and consistency) of one row, offline.
239pub 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
278/// Binding + inclusion: the evidence is FOR this artifact (its leaf hash
279/// re-derives from the canonical `sha256:<hex>` digest string) AND the
280/// Merkle inclusion/consistency proof checks out. Operator attestation of
281/// the checkpoint is a separate axis — see [`verify_artifact_log_inclusion`].
282fn 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
294/// Verify, fully offline, that an artifact's digest is anchored in a
295/// transparency log operated by the **pinned** key — the all-or-nothing
296/// verdict an artifact verifier needs (vs. the per-axis row verdicts of
297/// [`verify_evidence_pack_offline`]).
298///
299/// Three checks, each fail-closed:
300/// 1. the evidence binds to *this* artifact (`leaf_hash =
301///    hash_leaf(artifact_digest)`);
302/// 2. the Merkle inclusion (and any consistency) proof verifies against the
303///    embedded signed checkpoint;
304/// 3. the checkpoint signature verifies under the caller's pinned log key —
305///    never under the key the evidence itself carries.
306///
307/// Args:
308/// * `artifact_digest`: The canonical `sha256:<64 hex>` digest string — the
309///   exact leaf data the log appended.
310/// * `t`: The inclusion evidence (what `LogWriter::prove` mints).
311/// * `pinned_log_key`: The log operator's Ed25519 key, pinned out of band.
312///
313/// Usage:
314/// ```ignore
315/// verify_artifact_log_inclusion("sha256:ab…", &evidence, &pinned_key)?;
316/// ```
317pub 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
332/// Verify an offline evidence pack with **zero network**.
333///
334/// Checks the embedded [`AirGappedOrgBundle`] integrity (every event
335/// self-addresses AND is signed by the controlling key-state), confirms the org
336/// is a pinned root, flags KEL duplicity, then for each row re-derives
337/// authority-at-release from the embedded KEL (tamper check) and verifies any
338/// transparency inclusion/consistency proof. With a `pinned_log_key`, each
339/// row's [`SignedCheckpoint`] signature is also verified against that pinned
340/// operator key ([`SignedCheckpoint::verify_log_signature`]) — "in the log"
341/// becomes operator-attested non-repudiation, and a forged or backdated
342/// checkpoint surfaces as `checkpoint_attested == Some(false)`. With no pinned
343/// log key the verdict is Merkle membership only, reported honestly as
344/// `checkpoint_attested == None`.
345///
346/// Args:
347/// * `pack`: The pack to verify (must embed an org bundle).
348/// * `pinned_roots`: The verifier's pinned trust roots.
349/// * `pinned_log_key`: The log operator's Ed25519 key, pinned out of band;
350///   `None` skips the checkpoint-signature axis (and the verdict says so).
351///
352/// Usage:
353/// ```ignore
354/// let verdicts = verify_evidence_pack_offline(&pack, &roots, Some(&log_key))?;
355/// assert!(verdicts.iter().all(|v| v.authority_consistent));
356/// ```
357pub 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        // The proof must be FOR this row's artifact: the leaf is the canonical
395        // digest string, so a valid proof over some other leaf is a mismatch,
396        // not evidence.
397        let transparency_verified = row
398            .transparency
399            .as_ref()
400            .map(|t| verify_inclusion_binds_artifact(&row.artifact_digest, t).is_ok());
401
402        // The operator axis: only decidable when the verifier pinned a log key
403        // AND the row anchors to a checkpoint — anything else is honestly None.
404        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// ── JSON contract (the WASM/FFI-facing form) ───────────────────────────────
422
423/// The tagged verdict envelope for [`verify_evidence_pack_offline_json`].
424#[derive(Serialize)]
425#[serde(tag = "kind", rename_all = "camelCase")]
426enum PackVerdictJson {
427    /// Verification ran to completion; one verdict per evidence row.
428    #[serde(rename = "verdicts")]
429    Verdicts {
430        /// Per-row offline verdicts, in pack order.
431        rows: Vec<RowVerdict>,
432    },
433    /// Verification failed closed (tampered pack, unpinned root, bad input).
434    #[serde(rename = "error")]
435    Error {
436        /// The stable `AUTHS-Exxxx` code.
437        code: String,
438        /// Human-readable detail.
439        message: String,
440    },
441}
442
443/// A last-resort verdict used only if envelope serialization itself fails.
444const SERIALIZE_FALLBACK: &str =
445    r#"{"kind":"error","code":"AUTHS-E2301","message":"verdict serialization failed"}"#;
446
447/// Verify an offline evidence pack from its JSON wire forms — the
448/// string-in/string-out contract the WASM surface exposes.
449///
450/// Panic-free and synchronous: malformed or oversize input returns a tagged
451/// `error` envelope, never an exception. The verdict is a discriminated union
452/// (`kind`: `"verdicts"` | `"error"`), never a bare bool.
453///
454/// Args:
455/// * `pack_json`: The [`EvidencePack`] JSON (the `.evidence` file).
456/// * `pinned_roots_json`: JSON array of the verifier's pinned `did:keri:` roots.
457/// * `pinned_log_key_hex`: The pinned log operator key (64 hex chars, Ed25519),
458///   or `None` for a membership-only verdict.
459///
460/// Usage:
461/// ```ignore
462/// let verdict = verify_evidence_pack_offline_json(&pack, r#"["did:keri:EOrg"]"#, None);
463/// ```
464pub 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
480/// Parse a pinned log key from its hex wire form (fail-closed on malformed input).
481pub 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        // The reality today: no independent commons → not policy_met.
524        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        // The unclassifiable row must NOT be silently rendered as authorized.
593        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        // The predicate carries the authority verdicts.
607        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            // A checkpoint over a DIFFERENT root with no consistency proof must fail.
691            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    /// A checkpoint genuinely signed by `signing_key` over its note body.
701    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    /// Evidence for `digest` in a two-leaf log signed by `signing_key`.
725    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}