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,
})
}