Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

update-bundle/src/verify.rs

//! The offline verification pipeline. Operates over an in-memory [`MemberMap`] so
//! it is fully testable without a container and so a bundle is verified against the
//! exact bytes it will be extracted from.
//!
//! Order of checks (fail-closed, most-fundamental first):
//! 0. the policy states a coherent trust posture (`trust_mode`) — an unstated one
//!    is a refusal, never a permissive default
//! 1. enumerate members, path-safety, count/size limits (done at load time)
//! 2. release-manifest present; every member's dual digest matches it; no extra /
//!    missing members
//! 3. release signature valid over the manifest bytes; the release certificate is
//!    inside its validity window; release signer authorized + anchored per the
//!    policy's trust mode
//! 4. candidate digest = sha256(core.json) matches the manifest; parse core
//! 5. envelope (manifest.json) consistent with core
//! 6. every approval signature valid over exact statement bytes; each approver
//!    certificate inside its validity window; signer authorized for the claimed
//!    role by fingerprint; anchored per the policy's trust mode
//! 7. two-person rule: distinct approvers, required roles each by a distinct cert,
//!    release signer distinct
//! 8. expiry / maximum age
//! 9. required evidence present
//! 10. target product / model / revision
//! 11. sequence validity against local accepted state

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

use crate::approval::ApprovalStatement;
use crate::core::{BundleCore, EvidenceKind};
use crate::digest::sha256_hex;
use crate::error::{BundleError, Result};
use crate::manifest::{EnvelopeManifest, ReleaseManifest};
use crate::policy::{TrustMode, TrustPolicy};
use crate::rollback::AcceptedState;
use crate::schema::{
    APPROVALS_PREFIX, CORE_JSON, MANIFEST_JSON, RELEASE_CRT, RELEASE_MANIFEST_JSON, RELEASE_SIG,
};
use crate::signer::{chains_to_root, SignerCert};
use crate::MemberMap;

/// The target the bundle is being verified against (all optional; `None` = any).
#[derive(Debug, Clone, Default)]
pub struct TargetSpec {
    pub product: Option<String>,
    pub model: Option<String>,
    pub revision: Option<String>,
}

/// A recorded, verified approval.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRecord {
    pub role: String,
    pub fingerprint: String,
    pub subject: String,
}

/// The result of a successful verification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyReport {
    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>,
    /// True when the recorded advisory evidence is not production intelligence.
    pub non_production_evidence: bool,
    /// Human-readable list of the checks that passed.
    pub checks: Vec<String>,
}

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

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

/// Parse the trusted-root certificates from the policy.
///
/// Public because the trust anchors are not private to bundle verification: the
/// backup path in `holger-server` authorizes its own signer against the same
/// policy and must chain it to the same roots (see
/// [`verify_detached_by_trusted_signer`]).
pub fn collect_roots(policy: &TrustPolicy) -> Result<Vec<SignerCert>> {
    let mut out = Vec::new();
    for der in policy.root_certs_der()? {
        out.push(SignerCert::parse(&der)?);
    }
    Ok(out)
}

/// The identity behind a verified detached signature.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignerIdentity {
    pub fingerprint: String,
    pub subject: String,
}

/// Verify a detached ed25519 signature over `signed_bytes`, made by the X.509
/// certificate in `cert_der`, and authorize that certificate against `policy`.
///
/// This is the bundle path's signer machinery with the bundle-specific shape
/// lifted off, so a second document class can be authenticated the same way
/// instead of growing its own crypto. The authorization is deliberately identical
/// in kind to the release-signature step: the fingerprint must be named by the
/// policy — either as a `trusted_release_signer` or as an `approved_signer`
/// carrying `role` — and the certificate must chain to a trusted root.
///
/// Fail-closed: the signature is checked before anything else, an unparsable
/// certificate is a refusal, and a signer the policy does not name is a refusal
/// even when its signature is perfectly valid.
pub fn verify_detached_by_trusted_signer(
    signed_bytes: &[u8],
    signature: &[u8],
    cert_der: &[u8],
    policy: &TrustPolicy,
    role: &str,
) -> Result<SignerIdentity> {
    verify_detached_by_trusted_signer_at(
        signed_bytes,
        signature,
        cert_der,
        policy,
        role,
        Utc::now(),
    )
}

/// [`verify_detached_by_trusted_signer`] with the verification instant supplied
/// explicitly, so a test can decide what "now" is instead of racing the wall clock.
/// The wall-clock wrapper above is what production calls; this is the same function
/// with its one hidden input made visible.
pub fn verify_detached_by_trusted_signer_at(
    signed_bytes: &[u8],
    signature: &[u8],
    cert_der: &[u8],
    policy: &TrustPolicy,
    role: &str,
    now: DateTime<Utc>,
) -> Result<SignerIdentity> {
    let mode = policy.trust_mode()?;
    let cert = SignerCert::parse(cert_der)?;
    cert.ensure_valid_under(now, policy.certificate_validity)?;
    cert.verify(signed_bytes, signature)
        .map_err(|_| BundleError::InvalidApprovalSignature {
            role: role.to_string(),
            fingerprint: cert.fingerprint().to_string(),
        })?;
    let authorized = policy.is_release_signer(cert.fingerprint())
        || policy.authorizes_role(cert.fingerprint(), role);
    if !authorized {
        return Err(BundleError::UntrustedSigner {
            fingerprint: cert.fingerprint().to_string(),
            role: role.to_string(),
        });
    }
    let roots = collect_roots(policy)?;
    if !chains_to_root(&cert, &roots, mode) {
        return Err(BundleError::UntrustedSigner {
            fingerprint: cert.fingerprint().to_string(),
            role: role.to_string(),
        });
    }
    Ok(SignerIdentity {
        fingerprint: cert.fingerprint().to_string(),
        subject: cert.subject().to_string(),
    })
}

/// Bind ONE file `core.json` declares to the member actually in the bundle.
///
/// Missing, wrong size, or either digest wrong is a refusal. The digests are
/// compared case-insensitively because a hex string is a hex string, and the
/// size is compared because a length mismatch is the cheapest way to catch a
/// truncation before hashing tells you the same thing more slowly.
fn bind_declared_member(
    members: &MemberMap,
    relative_path: &str,
    want_sha256: &str,
    want_blake3: &str,
    want_size: u64,
) -> Result<()> {
    let bytes = member(members, relative_path)?;
    if bytes.len() as u64 != want_size {
        return Err(BundleError::DigestMismatch {
            path: relative_path.to_string(),
            which: "size",
        });
    }
    let got_sha = sha256_hex(bytes);
    if !got_sha.eq_ignore_ascii_case(want_sha256) {
        return Err(BundleError::DigestMismatch {
            path: relative_path.to_string(),
            which: "sha256",
        });
    }
    let got_blake = crate::digest::blake3_hex(bytes);
    if !got_blake.eq_ignore_ascii_case(want_blake3) {
        return Err(BundleError::DigestMismatch {
            path: relative_path.to_string(),
            which: "blake3",
        });
    }
    Ok(())
}

/// Verify every approval statement's signature over its exact stored bytes, bind
/// each to `candidate_digest`, and authorize the claimed role strictly by the
/// signer certificate fingerprint against `policy`. Returns the approving records.
pub(crate) fn verify_approvals(
    members: &MemberMap,
    policy: &TrustPolicy,
    candidate_digest: &str,
    roots: &[SignerCert],
    mode: TrustMode,
    now: DateTime<Utc>,
) -> Result<Vec<ApprovalRecord>> {
    let mut approval_paths: Vec<&String> = members
        .keys()
        .filter(|k| k.starts_with(APPROVALS_PREFIX) && k.ends_with(".approval.json"))
        .collect();
    approval_paths.sort();
    if approval_paths.len() > crate::schema::MAX_APPROVALS {
        return Err(BundleError::LimitExceeded(
            "too many approval statements".into(),
        ));
    }
    let mut approvals = Vec::new();
    for ap in approval_paths {
        let stmt_bytes = member(members, ap)?;
        let stmt = ApprovalStatement::from_json_bytes(stmt_bytes)?;
        let sig = member(members, &stmt.signature_path)?;
        let crt_path = cert_path_for(&stmt.signature_path)?;
        let crt = member(members, &crt_path)?;
        let cert = SignerCert::parse(crt)?;
        // The approver's certificate must be IN FORCE, not merely listed. A
        // two-person rule satisfied by a certificate that lapsed three years ago is
        // one person and one artefact.
        cert.ensure_valid_under(now, policy.certificate_validity)?;
        cert.verify(stmt_bytes, sig)
            .map_err(|_| BundleError::InvalidApprovalSignature {
                role: stmt.approval_role.clone(),
                fingerprint: cert.fingerprint().to_string(),
            })?;
        if !stmt
            .signer_certificate_fingerprint
            .eq_ignore_ascii_case(cert.fingerprint())
        {
            return Err(BundleError::InvalidApprovalSignature {
                role: stmt.approval_role.clone(),
                fingerprint: cert.fingerprint().to_string(),
            });
        }
        if !stmt.candidate_digest.eq_ignore_ascii_case(candidate_digest) {
            return Err(BundleError::Policy(format!(
                "approval by {} is for a different candidate digest",
                cert.fingerprint()
            )));
        }
        if !policy.authorizes_role(cert.fingerprint(), &stmt.approval_role) {
            return Err(BundleError::UntrustedSigner {
                fingerprint: cert.fingerprint().to_string(),
                role: stmt.approval_role.clone(),
            });
        }
        if !chains_to_root(&cert, roots, mode) {
            return Err(BundleError::UntrustedSigner {
                fingerprint: cert.fingerprint().to_string(),
                role: stmt.approval_role.clone(),
            });
        }
        if stmt.is_approve() {
            approvals.push(ApprovalRecord {
                role: stmt.approval_role,
                fingerprint: cert.fingerprint().to_string(),
                subject: cert.subject().to_string(),
            });
        }
    }
    Ok(approvals)
}

/// Enforce distinct-approver count, required-role coverage by distinct certificates,
/// and release-signer distinctness. One certificate cannot satisfy two required roles.
pub(crate) 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(BundleError::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(BundleError::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();
    if !required.is_empty() {
        let fps: Vec<&str> = distinct.clone();
        let candidates: Vec<Vec<usize>> = required
            .iter()
            .map(|role| {
                fps.iter()
                    .enumerate()
                    .filter(|(_, fp)| {
                        approvals
                            .iter()
                            .any(|a| &a.fingerprint == *fp && a.role.as_str() == *role)
                    })
                    .map(|(i, _)| i)
                    .collect()
            })
            .collect();
        let mut match_fp: Vec<Option<usize>> = vec![None; fps.len()];
        for (role_idx, role) in required.iter().enumerate() {
            let mut seen = vec![false; fps.len()];
            if !try_assign(role_idx, &candidates, &mut match_fp, &mut seen) {
                return Err(BundleError::MissingRole((*role).to_string()));
            }
        }
    }
    Ok(())
}

fn try_assign(
    role_idx: usize,
    candidates: &[Vec<usize>],
    match_fp: &mut [Option<usize>],
    seen: &mut [bool],
) -> bool {
    for &fp in &candidates[role_idx] {
        if seen[fp] {
            continue;
        }
        seen[fp] = true;
        if match_fp[fp].is_none() || try_assign(match_fp[fp].unwrap(), candidates, match_fp, seen) {
            match_fp[fp] = Some(role_idx);
            return true;
        }
    }
    false
}

/// Parse `signatures/release-manifest.json` and check its schema version.
fn parse_release_manifest(members: &MemberMap) -> Result<ReleaseManifest> {
    let manifest_bytes = member(members, RELEASE_MANIFEST_JSON)?;
    let manifest = ReleaseManifest::from_json_bytes(manifest_bytes)?;
    if manifest.schema_version != crate::schema::BUNDLE_SCHEMA {
        return Err(BundleError::UnsupportedSchema {
            found: manifest.schema_version,
            expected: crate::schema::BUNDLE_SCHEMA,
        });
    }
    Ok(manifest)
}

/// Check the release signature over the release manifest's exact bytes, and
/// authorize the signing certificate (named by the policy AND chaining to a
/// trusted root). Returns the parsed release certificate.
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 release_sig = member(members, RELEASE_SIG)?;
    let release_crt = member(members, RELEASE_CRT)?;
    let release_cert = SignerCert::parse(release_crt)?;
    release_cert.ensure_valid_under(now, policy.certificate_validity)?;
    release_cert
        .verify(manifest_bytes, release_sig)
        .map_err(|_| BundleError::InvalidReleaseSignature)?;
    if !policy.is_release_signer(release_cert.fingerprint()) {
        return Err(BundleError::UntrustedReleaseSigner(
            release_cert.fingerprint().to_string(),
        ));
    }
    if !chains_to_root(&release_cert, roots, mode) {
        return Err(BundleError::UntrustedReleaseSigner(
            release_cert.fingerprint().to_string(),
        ));
    }
    Ok(release_cert)
}

/// Check every member that IS present against the manifest, without requiring the
/// manifest's other members to be present yet.
///
/// This is [`ReleaseManifest::verify_members`] minus its second half (the
/// "every referenced member exists" sweep), which cannot hold while only part of
/// the bundle has been extracted. A member present but NOT referenced is still a
/// refusal here — a stowaway is a stowaway whether or not its neighbours have
/// been read yet.
fn verify_present_members_against(manifest: &ReleaseManifest, present: &MemberMap) -> Result<()> {
    let index = manifest.index();
    for (path, bytes) in present {
        if crate::manifest::is_self_referential(path) {
            continue;
        }
        let entry = index
            .get(path.as_str())
            .ok_or_else(|| BundleError::UnreferencedMember(path.clone()))?;
        if entry.size_bytes != bytes.len() as u64 {
            return Err(BundleError::DigestMismatch {
                path: path.clone(),
                which: "size",
            });
        }
        if !entry.sha256.eq_ignore_ascii_case(&sha256_hex(bytes)) {
            return Err(BundleError::DigestMismatch {
                path: path.clone(),
                which: "sha256",
            });
        }
        if !entry
            .blake3
            .eq_ignore_ascii_case(&crate::digest::blake3_hex(bytes))
        {
            return Err(BundleError::DigestMismatch {
                path: path.clone(),
                which: "blake3",
            });
        }
    }
    Ok(())
}

/// Everything the CONTROL members alone can prove — the authenticated release
/// manifest and the approvals that stand behind it.
#[derive(Debug, Clone)]
pub struct ControlVerdict {
    /// The release manifest, now covered by a verified signature from a signer the
    /// policy trusts. Its `members` list is the only size information about the
    /// rest of the bundle that may be believed.
    pub manifest: ReleaseManifest,
    /// `sha256(core.json)` — the digest every approval binds.
    pub candidate_digest: String,
    pub release_signer_fingerprint: String,
    pub approvals: Vec<ApprovalRecord>,
}

/// Verify the bundle's control plane from the control members ALONE.
///
/// `control` must hold `core.json`, the envelope, and everything under
/// `signatures/` and `approvals/` — and nothing else that the caller has not yet
/// bounded. `payload/**` and `evidence/**` are deliberately absent: this function
/// exists so the release signature and the two-person rule can be decided BEFORE
/// a single payload byte is decompressed, which is what makes a decompression
/// bomb a refusal instead of an out-of-memory kill.
///
/// It runs steps 2, 3, 4, 6 and 7 of the pipeline (release manifest binds the
/// present members · release signature + signer authorization · candidate digest ·
/// approvals · two-person rule). The checks it does NOT run are exactly the ones
/// that need the whole bundle or the caller's context — every referenced member is
/// present, `core.json`'s content list binds, evidence, target, expiry, sequence —
/// and [`verify_member_map`] runs the complete pipeline again over the assembled
/// map afterwards. This is a gate placed in front of extraction, never a
/// replacement for verification.
pub fn verify_control_plane(control: &MemberMap, policy: &TrustPolicy) -> Result<ControlVerdict> {
    verify_control_plane_at(control, policy, Utc::now())
}

/// [`verify_control_plane`] with the verification instant supplied explicitly.
///
/// The wall-clock wrapper is what the extraction path calls; the instant matters
/// here only for the certificate validity windows, and the full pipeline asks the
/// same question again in [`verify_member_map`] with the caller's own `now`.
pub fn verify_control_plane_at(
    control: &MemberMap,
    policy: &TrustPolicy,
    now: DateTime<Utc>,
) -> Result<ControlVerdict> {
    let mode = policy.trust_mode()?;
    let manifest = parse_release_manifest(control)?;
    verify_present_members_against(&manifest, control)?;

    let roots = collect_roots(policy)?;
    let release_cert = 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(BundleError::CandidateDigestMismatch);
    }

    // The verdict is read here too, so a blocked bundle is refused before a
    // single payload byte is decompressed.
    BundleCore::from_json_bytes(core_bytes)?.require_pass_verdict()?;

    let approvals = verify_approvals(control, policy, &candidate_digest, &roots, mode, now)?;
    enforce_two_person(policy, &approvals, release_cert.fingerprint())?;

    Ok(ControlVerdict {
        manifest,
        candidate_digest,
        release_signer_fingerprint: release_cert.fingerprint().to_string(),
        approvals,
    })
}

/// Verify a bundle held entirely in memory. `now` is passed explicitly for
/// deterministic testing; the file-level wrapper uses the wall clock.
///
/// **This signature does not change and never will** — it is the published shape
/// and the seam W13 was built on. What W13 adds is
/// [`verify_member_map_with_time`], which takes the SOURCE the instant came from
/// rather than a bare instant, so the report can say whether that source was
/// trusted. A caller who has only an instant keeps calling this.
pub fn verify_member_map(
    members: &MemberMap,
    policy: &TrustPolicy,
    target: &TargetSpec,
    local_state: Option<&AcceptedState>,
    now: DateTime<Utc>,
) -> Result<VerifyReport> {
    verify_member_map_inner(members, policy, target, local_state, now, None)
}

/// [`verify_member_map`] with the time SOURCE supplied instead of a bare instant.
///
/// The verdict this returns carries an extra line in [`VerifyReport::checks`]
/// naming the source and saying whether it was trusted — `time source: host-rtc
/// (UNTRUSTED — this box has no verified time)` or `time source: estate-clock
/// (verified)`. Nothing about what is ACCEPTED changes: the same comparison runs
/// against the same instant. What changes is that a verdict taken against a
/// battery-backed guess now says so in its own evidence, which is W13's whole
/// claim.
///
/// # Errors
///
/// Every refusal [`verify_member_map`] makes, plus [`BundleError::Policy`] when
/// the time source itself cannot answer — a signed source whose signature did not
/// verify is a REFUSAL here, not a silent fall back to the host clock.
pub fn verify_member_map_with_time(
    members: &MemberMap,
    policy: &TrustPolicy,
    target: &TargetSpec,
    local_state: Option<&AcceptedState>,
    time: &dyn crate::time::TrustedTime,
) -> Result<VerifyReport> {
    let now = time.now().map_err(|e| {
        BundleError::Policy(format!(
            "the time source {:?} could not answer, so no expiry can be checked: {e}",
            time.source_name()
        ))
    })?;
    verify_member_map_inner(members, policy, target, local_state, now, Some(time))
}

fn verify_member_map_inner(
    members: &MemberMap,
    policy: &TrustPolicy,
    target: &TargetSpec,
    local_state: Option<&AcceptedState>,
    now: DateTime<Utc>,
    time: Option<&dyn crate::time::TrustedTime>,
) -> Result<VerifyReport> {
    let mut checks: Vec<String> = Vec::new();

    // The source of `now` is the FIRST thing recorded, before any check that
    // rests on it. A caller that handed over a bare instant gets the honest
    // answer — the instant came from somewhere this function cannot name, which
    // in every production path today is the host RTC.
    checks.push(match time {
        Some(t) if t.is_trusted() => format!("time source: {} (verified)", t.source_name()),
        Some(t) => format!(
            "time source: {} (UNTRUSTED — this box has no verified time, so every \
             expiry below was checked against a clock nobody signed)",
            t.source_name()
        ),
        None => "time source: unnamed (the caller supplied a bare instant; \
                 every expiry below was checked against a clock nobody signed)"
            .to_string(),
    });

    // --- 0. the policy must state a coherent trust posture ---
    //
    // Before the first certificate is looked at: a policy that does not say HOW it
    // anchors signers cannot be used to decide anything, and the absence must be a
    // refusal rather than a permissive default. This runs at every verification,
    // not only at `TrustPolicy::from_ron`, because a policy can also be built in
    // memory by a caller that never went through the parser.
    let mode = policy.trust_mode()?;
    checks.push(format!("trust posture stated by the policy: {mode:?}"));

    // --- 2. release manifest binds every member ---
    let manifest = parse_release_manifest(members)?;
    manifest.verify_members(members)?;
    checks.push("every member digest matches the release manifest".into());

    // --- 3. release signature + authorization ---
    let roots = collect_roots(policy)?;
    let release_cert = authenticate_release_signature(members, policy, &roots, mode, now)?;
    checks.push("release signature valid and signer authorized".into());

    // --- 4. candidate digest + core ---
    let core_bytes = member(members, CORE_JSON)?;
    let candidate_digest = sha256_hex(core_bytes);
    if candidate_digest != manifest.candidate_digest {
        return Err(BundleError::CandidateDigestMismatch);
    }
    let core = BundleCore::from_json_bytes(core_bytes)?;
    if !core.schema_ok() {
        return Err(BundleError::UnsupportedSchema {
            found: core.schema_version.clone(),
            expected: crate::schema::BUNDLE_SCHEMA,
        });
    }
    checks.push("candidate digest matches core.json".into());

    // --- 4a. the reviewers' verdict ---
    //
    // core.json is what the approvers and the release signer signed, so the
    // verdict in it is signed too. Signatures prove WHO decided; the verdict is
    // WHAT they decided. Before B1 this function never read it, and a bundle the
    // reviewers marked "block" verified and imported.
    core.require_pass_verdict()?;
    checks.push(format!(
        "policy verdict is {:?}",
        core.policy_result.verdict
    ));

    // --- 4b. core.json's content list binds to the actual members ---
    //
    // The approvals in step 6 sign `candidate_digest`, which is sha256(core.json).
    // So what the approvers attest to is the CONTENT LIST core.json declares: this
    // payload with this digest, this evidence with that digest. Until this check
    // existed, nothing compared that list to the bundle: members were verified
    // against the RELEASE MANIFEST (step 2) and core.json against its own digest
    // (step 4), and the two were never joined.
    //
    // The gap that opens: a release signer — one key, not the two the policy
    // demands — could ship a bundle whose manifest and members agree perfectly
    // with each other while declaring a different payload than the one the
    // approvers read and signed. The two-person rule would hold on paper and
    // cover a content list nobody checked.
    //
    // Both digests are compared, because both are declared; a member that
    // matches on sha256 and not on blake3 is not the file core.json names.
    for payload in &core.payloads {
        bind_declared_member(
            members,
            &payload.relative_path,
            &payload.sha256,
            &payload.blake3,
            payload.size_bytes,
        )?;
    }
    for evidence in &core.evidence {
        bind_declared_member(
            members,
            &evidence.relative_path,
            &evidence.sha256,
            &evidence.blake3,
            evidence.size_bytes,
        )?;
    }
    // …and the SAME QUESTION FROM THE OTHER SIDE. The loops above prove every
    // declared file is present and correct; they prove nothing about a file that
    // is present and NOT declared. That direction is the one an attacker uses,
    // because it is the one the rest of the pipeline waves through: the release
    // manifest is built FROM the members, so an added member appears in it and
    // passes step 2, and `core.json` is untouched so `candidate_digest` — the
    // thing the approvers actually signed — is untouched too. Both approval
    // signatures still verify over a content list that no longer describes the
    // bundle. A list is only binding if it is complete AND exact.
    //
    // Only the payload and evidence trees are held to this. The control members
    // (core.json, the manifests, signatures/, approvals/) are named by the schema
    // rather than by core.json, and the release manifest is what governs them.
    let declared: std::collections::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(crate::schema::PAYLOAD_PREFIX)
            || path.starts_with(crate::schema::EVIDENCE_PREFIX);
        if is_content && !declared.contains(path.as_str()) {
            return Err(BundleError::UnreferencedMember(path.clone()));
        }
    }
    checks.push(
        "core.json's content list is complete and exact — every declared file is present,          and every payload/evidence member is declared"
            .into(),
    );

    // --- 5. envelope cross-check (if present) ---
    if let Some(env_bytes) = members.get(MANIFEST_JSON) {
        let env = EnvelopeManifest::from_json_bytes(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(BundleError::EnvelopeMismatch(
                "manifest.json does not agree with core.json".into(),
            ));
        }
    }

    // --- 6. approvals ---
    let approvals = verify_approvals(members, policy, &candidate_digest, &roots, mode, now)?;
    checks.push(format!(
        "{} approval signature(s) valid and authorized",
        approvals.len()
    ));

    // --- 7. two-person rule ---
    enforce_two_person(policy, &approvals, release_cert.fingerprint())?;
    checks.push("two-person rule satisfied".into());

    // --- 8. expiry / max age ---
    if policy.require_expiry && core.expires_at.is_none() {
        return Err(BundleError::MissingExpiry);
    }
    // An expired bundle is REFUSED here under BOTH settings of
    // `policy.on_expired_metadata`, and that is not an oversight: this function
    // verifies an IMPORT, and an import is a WRITE — neither `ReadOnly` nor
    // `Refuse` admits a write of expired metadata. A downstream runs ReadOnly
    // unless its policy says Refuse (the owner's ruling 2026-09-28,
    // `TrustPolicy::expired_posture`). Its SERVE-time consumer does not exist yet.
    if let Some(exp) = core.expires_at {
        if now > exp {
            return Err(BundleError::Expired(exp.to_rfc3339()));
        }
    }
    if let Some(max_age) = policy.maximum_bundle_age_secs {
        let age = now.signed_duration_since(core.created_at);
        if age.num_seconds() > max_age as i64 {
            return Err(BundleError::TooOld);
        }
    }
    checks.push("bundle is within its validity window".into());

    // --- 9. required evidence ---
    if policy.require_sbom && !core.has_evidence_kind(EvidenceKind::Sbom) {
        return Err(BundleError::MissingEvidence("sbom"));
    }
    if policy.require_vex && !core.has_evidence_kind(EvidenceKind::Vex) {
        return Err(BundleError::MissingEvidence("vex"));
    }
    if policy.require_scan && !core.has_evidence_kind(EvidenceKind::Scan) {
        return Err(BundleError::MissingEvidence("scan"));
    }

    // --- 10. target ---
    if let Some(p) = &target.product {
        if &core.product != p {
            return Err(BundleError::TargetMismatch(format!(
                "product {:?} != bundle product {:?}",
                p, core.product
            )));
        }
    }
    if !policy.allowed_products.is_empty() && !policy.allowed_products.contains(&core.product) {
        return Err(BundleError::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(BundleError::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(BundleError::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(BundleError::TargetMismatch(format!(
                "revision {r:?} not supported by this bundle"
            )));
        }
    }
    checks.push("target product / model / revision accepted".into());

    // --- 11. sequence vs local accepted state ---
    if let Some(state) = local_state {
        state.check_admissible(&core, &candidate_digest)?;
        checks.push("sequence is newer than the accepted state".into());
    }

    Ok(VerifyReport {
        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_cert.fingerprint().to_string(),
        release_signer_subject: release_cert.subject().to_string(),
        approvals,
        non_production_evidence: core.policy_result.non_production,
        checks,
    })
}