Gunnar

nordisk/heimdall

guests/verify-bundle/src/verify.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The verification pipeline: Holger's `container::load_bundle_verified`
//! followed by `verify::verify_member_map_inner`, over the archive bytes the
//! guest was handed, and then the DSSE step.
//!
//! Order (fail-closed, cheapest and most fundamental first):
//! 0. the parameters: a coherent policy (`trust_mode`), a `now`
//! 1. the archive: size cap, footer, manifest, index, chunk geometry, every
//!    member name through the path guard, the member count, the bomb SHAPE from
//!    the declared sizes — before one blob byte is read
//! 2. the control members only (`core.json`, `manifest.json`, `signatures/**`,
//!    `approvals/**`), under their own per-member and total caps
//! 3. the control plane: the release manifest binds the present members, the
//!    release signature over its exact bytes, the release signer named and
//!    anchored, the candidate digest, the verdict, the approvals, two persons
//! 4. every other member: named by the SIGNED manifest before it is read, the
//!    signed sizes budgeted, each read held to its signed size
//! 5. the whole bundle again, Holger's full order (content list both ways,
//!    envelope, approvals, two-person, expiry, evidence, target, sequence)
//! 6. DSSE envelopes: signature before statement, statement bound to a payload

use std::collections::{BTreeMap, BTreeSet};

use chrono::{DateTime, Utc};
use serde::Serialize;

use crate::cert::{chains_to_root, SignerCert};
use crate::digest::{blake3_hex, sha256_hex};
use crate::dsse::{self, DsseTrust, VerifiedEnvelope};
use crate::limits::*;
use crate::model::{
    check_admissible, ApprovalStatement, BundleCore, EnvelopeManifest, EvidenceKind,
    ReleaseManifest, TrustMode, TrustPolicy,
};
use crate::params::VerifyParams;
use crate::refusal::{Refusal, Result};
use crate::znippy::{Archive, Source};

/// Path -> exact bytes, sorted.
pub type MemberMap = BTreeMap<String, Vec<u8>>;

/// Path -> (SHA-256, BLAKE3-256) of the member, lowercase hex. Computed ONCE
/// per member and used by every step that compares a digest (Holger's order
/// checks each member against the manifest and core.json and reports it).
pub type Digests = BTreeMap<String, (String, String)>;

/// Every member's digests, computed here.
fn digests_here(members: &MemberMap) -> Digests {
    members
        .iter()
        .map(|(p, b)| (p.clone(), (sha256_hex(b), blake3_hex(b))))
        .collect()
}

/// Every member's digests: from the split guest's host (natively, in
/// parallel) when the decoder offers them, else computed here.
fn member_digests(members: &MemberMap, src: &mut Source<'_>) -> Result<Digests> {
    let Source::Split(dec) = src else {
        return Ok(digests_here(members));
    };
    let blobs: Vec<&[u8]> = members.values().map(Vec::as_slice).collect();
    let Some(got) = dec.member_digests(&blobs) else {
        return Ok(digests_here(members));
    };
    if got.len() != members.len() {
        return Err(Refusal::Decoder(format!(
            "{} member digests for {} members",
            got.len(),
            members.len()
        )));
    }
    let mut out = Digests::new();
    for (p, d) in members.keys().zip(got) {
        if d.sha256.len() != 32 || d.blake3.len() != 32 {
            return Err(Refusal::Decoder(format!(
                "member {p:?}: digests of {} and {} bytes, not 32",
                d.sha256.len(),
                d.blake3.len()
            )));
        }
        out.insert(
            p.clone(),
            (
                crate::digest::hex_lower(&d.sha256),
                crate::digest::hex_lower(&d.blake3),
            ),
        );
    }
    Ok(out)
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ApprovalRecord {
    pub role: String,
    pub fingerprint: String,
    pub subject: String,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DsseRecord {
    pub member: String,
    pub predicate_type: String,
    pub signers: Vec<String>,
    pub subjects_bound: Vec<String>,
}

/// What an accepted bundle is. Field names follow Holger's `VerifyReport`.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct VerifyReport {
    pub schema: &'static str,
    pub bundle_id: String,
    pub sequence: u64,
    pub supplier: String,
    pub product: String,
    pub release_version: String,
    pub candidate_digest: String,
    pub release_signer_fingerprint: String,
    pub release_signer_subject: String,
    pub approvals: Vec<ApprovalRecord>,
    pub non_production_evidence: bool,
    pub members: Vec<MemberRecord>,
    pub dsse: Vec<DsseRecord>,
    pub checks: Vec<String>,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct MemberRecord {
    pub path: String,
    /// The file name the member is emitted under ([`crate::output_name`]).
    pub output_name: String,
    pub size: u64,
    pub sha256: String,
    pub blake3: String,
}

pub const REPORT_SCHEMA: &str = "heimdall.verify-bundle.report/v1";

/// The whole pipeline. On success: the report and the verified members.
pub fn verify(input: &[u8], params: &VerifyParams) -> Result<(VerifyReport, MemberMap)> {
    verify_with(input, params, &mut Source::InProcess)
}

/// [`verify`] with compressed chunks decoded by `src` (the split guest hands
/// them to isolated decoder instances). Same verdicts as [`verify`].
pub fn verify_with(
    input: &[u8],
    params: &VerifyParams,
    src: &mut Source<'_>,
) -> Result<(VerifyReport, MemberMap)> {
    let policy = &params.policy;
    let now = params.now;
    let mode = policy.trust_mode()?;

    // ---- 1. the archive, names and shape, no blob read ----
    if input.len() as u64 > MAX_BUNDLE_FILE_BYTES {
        return Err(Refusal::LimitExceeded(format!(
            "bundle file size {} exceeds {MAX_BUNDLE_FILE_BYTES}",
            input.len()
        )));
    }
    let archive = Archive::parse(input)?;
    if archive.len() > MAX_MEMBERS {
        return Err(Refusal::LimitExceeded(format!(
            "member count {} exceeds {MAX_MEMBERS}",
            archive.len()
        )));
    }
    archive.check_expansion_ratio()?;

    // ---- 2. control members, and nothing else ----
    // Read in order; the total cap fires after the member that crosses it,
    // exactly where a member-by-member loop would stop.
    let names: Vec<&str> = archive.names().filter(|n| is_control_member(n)).collect();
    let mut control_total: u64 = 0;
    let mut upto = names.len();
    let mut over = false;
    for (i, name) in names.iter().enumerate() {
        let size = archive.entry(name).map_or(0, |e| e.size);
        control_total = control_total.saturating_add(size);
        if control_total > MAX_CONTROL_TOTAL_BYTES {
            upto = i + 1;
            over = true;
            break;
        }
    }
    let reqs: Vec<(&str, u64)> = names[..upto]
        .iter()
        .map(|n| (*n, MAX_CONTROL_BYTES))
        .collect();
    let read = archive.read_many(&reqs, src)?;
    if over {
        return Err(Refusal::LimitExceeded(format!(
            "control members exceed {MAX_CONTROL_TOTAL_BYTES} before anything is authenticated"
        )));
    }
    let mut control = MemberMap::new();
    for (name, bytes) in names.iter().zip(read) {
        control.insert(name.to_string(), bytes);
    }

    // ---- 3. the gate ----
    let roots = collect_roots(policy)?;
    let gate = verify_control_plane(&control, policy, &roots, mode, now)?;
    let signed = gate.manifest.index()?;

    // ---- 4. budget from SIGNED sizes, then read ----
    let mut budget = control_total;
    let content: Vec<&str> = archive.names().filter(|n| !is_control_member(n)).collect();
    for name in &content {
        let entry = signed
            .get(name)
            .ok_or_else(|| Refusal::UnreferencedMember(name.to_string()))?;
        if entry.size_bytes > MAX_MEMBER_BYTES {
            return Err(Refusal::LimitExceeded(format!(
                "member {name:?} signed size {} exceeds {MAX_MEMBER_BYTES}",
                entry.size_bytes
            )));
        }
        budget = budget.saturating_add(entry.size_bytes);
        if budget > MAX_TOTAL_BYTES {
            return Err(Refusal::LimitExceeded(format!(
                "signed member sizes total {budget}, over {MAX_TOTAL_BYTES} — refusing before extraction"
            )));
        }
        if archive.entry(name).map(|e| e.size) != Some(entry.size_bytes) {
            return Err(Refusal::DigestMismatch {
                path: name.to_string(),
                which: "size",
            });
        }
    }
    let mut members = control;
    let reqs: Vec<(&str, u64)> = content.iter().map(|n| (*n, signed[n].size_bytes)).collect();
    for (name, bytes) in content.iter().zip(archive.read_many(&reqs, src)?) {
        members.insert(name.to_string(), bytes);
    }
    drop(signed);

    // ---- 5. the full pipeline over the assembled bundle ----
    let digests = member_digests(&members, src)?;
    let mut report = verify_member_map(&members, &digests, params, &roots, mode, now)?;

    // ---- 6. DSSE ----
    let core = BundleCore::parse(&members[CORE_JSON])?;
    report.dsse = verify_dsse(&members, &core, params)?;
    if !report.dsse.is_empty() {
        report.checks.push(format!(
            "{} DSSE envelope(s) verified before their statements were read, each bound to a payload",
            report.dsse.len()
        ));
    }
    Ok((report, members))
}

fn collect_roots(policy: &TrustPolicy) -> Result<Vec<SignerCert>> {
    policy
        .root_certs_der()?
        .iter()
        .map(|d| SignerCert::parse(d))
        .collect()
}

fn member<'a>(members: &'a MemberMap, path: &str) -> Result<&'a Vec<u8>> {
    members
        .get(path)
        .ok_or_else(|| Refusal::MissingMember(path.to_string()))
}

fn parse_release_manifest(members: &MemberMap) -> Result<ReleaseManifest> {
    let m = ReleaseManifest::parse(member(members, RELEASE_MANIFEST_JSON)?)?;
    if m.schema_version != BUNDLE_SCHEMA {
        return Err(Refusal::UnsupportedSchema {
            found: m.schema_version,
            expected: BUNDLE_SCHEMA,
        });
    }
    Ok(m)
}

fn authenticate_release_signature(
    members: &MemberMap,
    policy: &TrustPolicy,
    roots: &[SignerCert],
    mode: TrustMode,
    now: DateTime<Utc>,
) -> Result<SignerCert> {
    let manifest_bytes = member(members, RELEASE_MANIFEST_JSON)?;
    let sig = member(members, RELEASE_SIG)?;
    let cert = SignerCert::parse(member(members, RELEASE_CRT)?)?;
    cert.ensure_valid_under(now, policy.certificate_validity)?;
    cert.verify(manifest_bytes, sig)
        .map_err(|_| Refusal::InvalidReleaseSignature)?;
    if !policy.is_release_signer(cert.fingerprint()) || !chains_to_root(&cert, roots, mode) {
        return Err(Refusal::UntrustedReleaseSigner(
            cert.fingerprint().to_string(),
        ));
    }
    Ok(cert)
}

/// Every PRESENT member against the manifest (Holger's
/// `verify_present_members_against`). A member present and not referenced is
/// a stowaway.
fn verify_present_members(
    manifest: &ReleaseManifest,
    present: &MemberMap,
    digests: &Digests,
) -> Result<()> {
    let index = manifest.index()?;
    for (path, bytes) in present {
        if is_self_referential(path) {
            continue;
        }
        let e = index
            .get(path.as_str())
            .ok_or_else(|| Refusal::UnreferencedMember(path.clone()))?;
        check_digest(path, bytes, digests, e.size_bytes, &e.sha256, &e.blake3)?;
    }
    Ok(())
}

fn check_digest(
    path: &str,
    bytes: &[u8],
    digests: &Digests,
    size: u64,
    sha: &str,
    b3: &str,
) -> Result<()> {
    if size != bytes.len() as u64 {
        return Err(Refusal::DigestMismatch {
            path: path.to_string(),
            which: "size",
        });
    }
    let (have_sha, have_b3) = match digests.get(path) {
        Some((s, b)) => (s.clone(), b.clone()),
        None => (sha256_hex(bytes), blake3_hex(bytes)),
    };
    if !sha.eq_ignore_ascii_case(&have_sha) {
        return Err(Refusal::DigestMismatch {
            path: path.to_string(),
            which: "sha256",
        });
    }
    if !b3.eq_ignore_ascii_case(&have_b3) {
        return Err(Refusal::DigestMismatch {
            path: path.to_string(),
            which: "blake3",
        });
    }
    Ok(())
}

struct ControlVerdict {
    manifest: ReleaseManifest,
}

fn verify_control_plane(
    control: &MemberMap,
    policy: &TrustPolicy,
    roots: &[SignerCert],
    mode: TrustMode,
    now: DateTime<Utc>,
) -> Result<ControlVerdict> {
    let manifest = parse_release_manifest(control)?;
    verify_present_members(&manifest, control, &digests_here(control))?;
    let release = authenticate_release_signature(control, policy, roots, mode, now)?;
    let core_bytes = member(control, CORE_JSON)?;
    let candidate_digest = sha256_hex(core_bytes);
    if candidate_digest != manifest.candidate_digest {
        return Err(Refusal::CandidateDigestMismatch);
    }
    BundleCore::parse(core_bytes)?.require_pass_verdict()?;
    let approvals = verify_approvals(control, policy, &candidate_digest, roots, mode, now)?;
    enforce_two_person(policy, &approvals, release.fingerprint())?;
    Ok(ControlVerdict { manifest })
}

fn cert_path_for(sig_path: &str) -> Result<String> {
    sig_path
        .strip_suffix(".sig")
        .map(|b| format!("{b}.crt"))
        .ok_or_else(|| {
            Refusal::Malformed(format!(
                "approval signature_path {sig_path:?} must end with .sig"
            ))
        })
}

fn verify_approvals(
    members: &MemberMap,
    policy: &TrustPolicy,
    candidate_digest: &str,
    roots: &[SignerCert],
    mode: TrustMode,
    now: DateTime<Utc>,
) -> Result<Vec<ApprovalRecord>> {
    let paths: Vec<&String> = members
        .keys()
        .filter(|k| k.starts_with(APPROVALS_PREFIX) && k.ends_with(".approval.json"))
        .collect();
    if paths.len() > MAX_APPROVALS {
        return Err(Refusal::LimitExceeded(
            "too many approval statements".into(),
        ));
    }
    let mut out = Vec::new();
    for ap in paths {
        let stmt_bytes = member(members, ap)?;
        let stmt = ApprovalStatement::parse(stmt_bytes)?;
        let sig = member(members, &stmt.signature_path)?;
        let cert = SignerCert::parse(member(members, &cert_path_for(&stmt.signature_path)?)?)?;
        cert.ensure_valid_under(now, policy.certificate_validity)?;
        let invalid = || Refusal::InvalidApprovalSignature {
            role: stmt.approval_role.clone(),
            fingerprint: cert.fingerprint().to_string(),
        };
        cert.verify(stmt_bytes, sig).map_err(|_| invalid())?;
        if !stmt
            .signer_certificate_fingerprint
            .eq_ignore_ascii_case(cert.fingerprint())
        {
            return Err(invalid());
        }
        if !stmt.candidate_digest.eq_ignore_ascii_case(candidate_digest) {
            return Err(Refusal::Policy(format!(
                "approval by {} is for a different candidate digest",
                cert.fingerprint()
            )));
        }
        if !policy.authorizes_role(cert.fingerprint(), &stmt.approval_role)
            || !chains_to_root(&cert, roots, mode)
        {
            return Err(Refusal::UntrustedSigner {
                fingerprint: cert.fingerprint().to_string(),
                role: stmt.approval_role.clone(),
            });
        }
        if stmt.is_approve() {
            out.push(ApprovalRecord {
                role: stmt.approval_role,
                fingerprint: cert.fingerprint().to_string(),
                subject: cert.subject().to_string(),
            });
        }
    }
    Ok(out)
}

/// Holger's `enforce_two_person`: distinct approvers, the release signer
/// distinct from them, and every required role held by a DISTINCT certificate
/// (a bipartite matching, so one certificate never satisfies two roles).
fn enforce_two_person(
    policy: &TrustPolicy,
    approvals: &[ApprovalRecord],
    release_fp: &str,
) -> Result<()> {
    let mut distinct: Vec<&str> = approvals.iter().map(|a| a.fingerprint.as_str()).collect();
    distinct.sort_unstable();
    distinct.dedup();
    if distinct.len() < policy.minimum_distinct_approvers {
        return Err(Refusal::TwoPersonRule(format!(
            "{} distinct approver(s), need {}",
            distinct.len(),
            policy.minimum_distinct_approvers
        )));
    }
    if policy.release_signer_must_be_distinct
        && approvals
            .iter()
            .any(|a| a.fingerprint.eq_ignore_ascii_case(release_fp))
    {
        return Err(Refusal::TwoPersonRule(
            "release signer must be distinct from every approver".into(),
        ));
    }
    let mut required: Vec<&str> = policy
        .required_approval_roles
        .iter()
        .map(String::as_str)
        .collect();
    required.sort_unstable();
    required.dedup();
    let candidates: Vec<Vec<usize>> = required
        .iter()
        .map(|role| {
            distinct
                .iter()
                .enumerate()
                .filter(|(_, fp)| {
                    approvals
                        .iter()
                        .any(|a| a.fingerprint == **fp && a.role == *role)
                })
                .map(|(i, _)| i)
                .collect()
        })
        .collect();
    let mut match_fp: Vec<Option<usize>> = vec![None; distinct.len()];
    for (ri, role) in required.iter().enumerate() {
        let mut seen = vec![false; distinct.len()];
        if !try_assign(ri, &candidates, &mut match_fp, &mut seen) {
            return Err(Refusal::MissingRole((*role).to_string()));
        }
    }
    Ok(())
}

fn try_assign(
    ri: usize,
    candidates: &[Vec<usize>],
    match_fp: &mut [Option<usize>],
    seen: &mut [bool],
) -> bool {
    for &fp in &candidates[ri] {
        if seen[fp] {
            continue;
        }
        seen[fp] = true;
        let free = match match_fp[fp] {
            None => true,
            Some(other) => try_assign(other, candidates, match_fp, seen),
        };
        if free {
            match_fp[fp] = Some(ri);
            return true;
        }
    }
    false
}

/// Holger's `verify_member_map_inner`, step for step.
fn verify_member_map(
    members: &MemberMap,
    digests: &Digests,
    params: &VerifyParams,
    roots: &[SignerCert],
    mode: TrustMode,
    now: DateTime<Utc>,
) -> Result<VerifyReport> {
    let policy = &params.policy;
    let target = &params.target;
    let mut checks = vec![
        format!(
            "time source: the caller's `now` ({}), not a clock the guest can read",
            now.to_rfc3339()
        ),
        format!("trust posture stated by the policy: {mode:?}"),
    ];

    let manifest = parse_release_manifest(members)?;
    verify_present_members(&manifest, members, digests)?;
    for m in &manifest.members {
        if !members.contains_key(&m.relative_path) {
            return Err(Refusal::MissingMember(m.relative_path.clone()));
        }
    }
    checks.push("every member digest matches the release manifest".into());

    let release = authenticate_release_signature(members, policy, roots, mode, now)?;
    checks.push("release signature valid and signer authorized".into());

    let core_bytes = member(members, CORE_JSON)?;
    let candidate_digest = sha256_hex(core_bytes);
    if candidate_digest != manifest.candidate_digest {
        return Err(Refusal::CandidateDigestMismatch);
    }
    let core = BundleCore::parse(core_bytes)?;
    if !core.schema_ok() {
        return Err(Refusal::UnsupportedSchema {
            found: core.schema_version.clone(),
            expected: BUNDLE_SCHEMA,
        });
    }
    checks.push("candidate digest matches core.json".into());
    core.require_pass_verdict()?;
    checks.push(format!(
        "policy verdict is {:?}",
        core.policy_result.verdict
    ));

    for p in &core.payloads {
        check_digest(
            &p.relative_path,
            member(members, &p.relative_path)?,
            digests,
            p.size_bytes,
            &p.sha256,
            &p.blake3,
        )?;
    }
    for e in &core.evidence {
        check_digest(
            &e.relative_path,
            member(members, &e.relative_path)?,
            digests,
            e.size_bytes,
            &e.sha256,
            &e.blake3,
        )?;
    }
    let declared: BTreeSet<&str> = core
        .payloads
        .iter()
        .map(|p| p.relative_path.as_str())
        .chain(core.evidence.iter().map(|e| e.relative_path.as_str()))
        .collect();
    for path in members.keys() {
        let is_content = path.starts_with(PAYLOAD_PREFIX) || path.starts_with(EVIDENCE_PREFIX);
        if is_content && !declared.contains(path.as_str()) {
            return Err(Refusal::UnreferencedMember(path.clone()));
        }
    }
    checks.push("core.json's content list is complete and exact".into());

    if let Some(env_bytes) = members.get(MANIFEST_JSON) {
        let env: EnvelopeManifest = serde_json::from_slice(env_bytes)?;
        if env.bundle_id != core.bundle_id
            || env.sequence != core.sequence
            || env.core_digest != candidate_digest
            || env.supplier != core.supplier
            || env.product != core.product
        {
            return Err(Refusal::EnvelopeMismatch(
                "manifest.json does not agree with core.json".into(),
            ));
        }
    }

    let approvals = verify_approvals(members, policy, &candidate_digest, roots, mode, now)?;
    checks.push(format!(
        "{} approval signature(s) valid and authorized",
        approvals.len()
    ));
    enforce_two_person(policy, &approvals, release.fingerprint())?;
    checks.push("two-person rule satisfied".into());

    if policy.require_expiry && core.expires_at.is_none() {
        return Err(Refusal::MissingExpiry);
    }
    if let Some(exp) = core.expires_at {
        if now > exp {
            return Err(Refusal::Expired(exp.to_rfc3339()));
        }
    }
    if let Some(max_age) = policy.maximum_bundle_age_secs {
        if now.signed_duration_since(core.created_at).num_seconds() > max_age as i64 {
            return Err(Refusal::TooOld);
        }
    }
    checks.push("bundle is within its validity window".into());

    if policy.require_sbom && !core.has_evidence_kind(EvidenceKind::Sbom) {
        return Err(Refusal::MissingEvidence("sbom"));
    }
    if policy.require_vex && !core.has_evidence_kind(EvidenceKind::Vex) {
        return Err(Refusal::MissingEvidence("vex"));
    }
    if policy.require_scan && !core.has_evidence_kind(EvidenceKind::Scan) {
        return Err(Refusal::MissingEvidence("scan"));
    }

    if let Some(p) = &target.product {
        if &core.product != p {
            return Err(Refusal::TargetMismatch(format!(
                "product {p:?} != bundle product {:?}",
                core.product
            )));
        }
    }
    if !policy.allowed_products.is_empty() && !policy.allowed_products.contains(&core.product) {
        return Err(Refusal::TargetMismatch(format!(
            "product {:?} not in policy allow-list",
            core.product
        )));
    }
    if let Some(m) = &target.model {
        if !core.supported_hardware_models.iter().any(|x| x == m) {
            return Err(Refusal::TargetMismatch(format!(
                "model {m:?} not supported by this bundle"
            )));
        }
    }
    if !policy.allowed_models.is_empty()
        && !core
            .supported_hardware_models
            .iter()
            .any(|m| policy.allowed_models.contains(m))
    {
        return Err(Refusal::TargetMismatch(
            "no supported model is in the policy allow-list".into(),
        ));
    }
    if let Some(r) = &target.revision {
        if !core.supported_hardware_revisions.iter().any(|x| x == r) {
            return Err(Refusal::TargetMismatch(format!(
                "revision {r:?} not supported by this bundle"
            )));
        }
    }
    checks.push("target product / model / revision accepted".into());

    if let Some(accepted) = &params.accepted {
        check_admissible(accepted, &core, &candidate_digest)?;
        checks.push("sequence is newer than the accepted state".into());
    }

    Ok(VerifyReport {
        schema: REPORT_SCHEMA,
        bundle_id: core.bundle_id,
        sequence: core.sequence,
        supplier: core.supplier,
        product: core.product,
        release_version: core.release_version,
        candidate_digest,
        release_signer_fingerprint: release.fingerprint().to_string(),
        release_signer_subject: release.subject().to_string(),
        approvals,
        non_production_evidence: core.policy_result.non_production,
        members: members
            .iter()
            .map(|(p, b)| {
                let (sha256, blake3) = digests
                    .get(p)
                    .cloned()
                    .unwrap_or_else(|| (sha256_hex(b), blake3_hex(b)));
                MemberRecord {
                    path: p.clone(),
                    output_name: crate::output_name(p),
                    size: b.len() as u64,
                    sha256,
                    blake3,
                }
            })
            .collect(),
        dsse: Vec::new(),
        checks,
    })
}

fn verify_dsse(
    members: &MemberMap,
    core: &BundleCore,
    params: &VerifyParams,
) -> Result<Vec<DsseRecord>> {
    let media: BTreeMap<&str, &str> = core
        .payloads
        .iter()
        .map(|p| (p.relative_path.as_str(), p.media_type.as_str()))
        .chain(
            core.evidence
                .iter()
                .map(|e| (e.relative_path.as_str(), e.media_type.as_str())),
        )
        .collect();
    let extra = dsse::decode_extra_certs(&params.dsse.certificates)?;
    let trust = DsseTrust {
        trusted: &params.dsse.trusted_signers,
        keys: dsse::decode_pinned_keys(&params.dsse.keys)?,
        certificates: members
            .values()
            .map(Vec::as_slice)
            .chain(extra.iter().map(Vec::as_slice))
            .collect(),
        now: params.now,
        validity: params.policy.certificate_validity,
    };
    let payload_shas: Vec<&str> = core.payloads.iter().map(|p| p.sha256.as_str()).collect();
    let mut out = Vec::new();
    for (path, bytes) in members {
        if !(path.starts_with(PAYLOAD_PREFIX) || path.starts_with(EVIDENCE_PREFIX)) {
            continue;
        }
        if !dsse::is_envelope_member(path, media.get(path.as_str()).copied()) {
            continue;
        }
        for v in dsse::verify_member(path, bytes, &trust, &payload_shas)? {
            let VerifiedEnvelope {
                member,
                predicate_type,
                signers,
                subjects_bound,
            } = v;
            out.push(DsseRecord {
                member,
                predicate_type,
                signers,
                subjects_bound,
            });
        }
    }
    if params.dsse.require_envelope && out.is_empty() {
        return Err(Refusal::Dsse(
            "the caller requires a DSSE envelope and the bundle carries none".into(),
        ));
    }
    Ok(out)
}