update-bundle/src/tests/integrity.rs
//! Bundle-integrity adversarial tests: tamper detection, structural checks,
//! expiry, and chain-to-root.
use chrono::Duration;
use super::testkit::*;
use crate::error::BundleError;
use crate::finalize::finalize;
use crate::manifest::ReleaseManifest;
use crate::policy::{ApprovedSigner, CertificateValidity, ExpiredPosture, TrustMode, TrustPolicy};
use crate::schema::{POLICY_SCHEMA, RELEASE_MANIFEST_JSON, RELEASE_SIG};
use crate::signer::sign_pkcs8;
use crate::verify::{verify_member_map, TargetSpec};
fn ok(b: &BuiltBundle) -> bool {
verify_member_map(&b.members, &b.policy, &TargetSpec::default(), None, b.now).is_ok()
}
#[test]
fn valid_signed_bundle_verifies() {
let b = valid_bundle();
let rep =
verify_member_map(&b.members, &b.policy, &TargetSpec::default(), None, b.now).unwrap();
assert_eq!(rep.approvals.len(), 2);
assert_eq!(rep.sequence, 5);
assert_eq!(rep.candidate_digest, b.candidate_digest);
assert!(rep.non_production_evidence);
}
#[test]
fn one_changed_payload_byte_fails() {
let mut b = valid_bundle();
b.members.get_mut("payload/firmware.bin").unwrap()[0] ^= 0x01;
assert!(!ok(&b), "a flipped payload byte must fail verification");
}
#[test]
fn one_changed_evidence_byte_fails() {
let mut b = valid_bundle();
b.members.get_mut("evidence/sbom/app.cdx.json").unwrap()[1] ^= 0x01;
assert!(!ok(&b));
}
#[test]
fn one_changed_core_byte_fails() {
let mut b = valid_bundle();
b.members.get_mut("core.json").unwrap()[10] ^= 0x01;
assert!(
!ok(&b),
"core tamper must break candidate digest / manifest"
);
}
#[test]
fn one_changed_release_manifest_byte_fails() {
let mut b = valid_bundle();
let last = b.members[RELEASE_MANIFEST_JSON].len() - 3;
b.members.get_mut(RELEASE_MANIFEST_JSON).unwrap()[last] ^= 0x01;
assert!(!ok(&b));
}
#[test]
fn one_changed_release_signature_byte_fails() {
let mut b = valid_bundle();
b.members.get_mut(RELEASE_SIG).unwrap()[0] ^= 0x01;
assert!(!ok(&b));
}
#[test]
fn missing_payload_member_fails() {
let mut b = valid_bundle();
b.members.remove("payload/firmware.bin");
assert!(!ok(&b));
}
#[test]
fn extra_unreferenced_member_fails() {
let mut b = valid_bundle();
b.members
.insert("payload/rogue.bin".into(), b"surprise".to_vec());
assert!(
!ok(&b),
"a member not in the release manifest must be rejected"
);
}
#[test]
fn missing_release_signature_fails() {
let mut b = valid_bundle();
b.members.remove(RELEASE_SIG);
assert!(!ok(&b));
}
#[test]
fn swapped_release_certificate_fails() {
let mut b = valid_bundle();
// A stranger's cert cannot make the existing release signature valid, and is
// not a trusted release signer.
let stranger = self_signed("stranger");
b.members
.insert(crate::schema::RELEASE_CRT.into(), stranger.cert_der);
assert!(!ok(&b));
}
#[test]
fn expired_bundle_fails() {
let b = valid_bundle();
let later = b.now + Duration::days(40);
assert!(verify_member_map(&b.members, &b.policy, &TargetSpec::default(), None, later).is_err());
}
#[test]
fn exceeding_maximum_age_fails() {
let mut b = valid_bundle();
b.policy.maximum_bundle_age_secs = Some(1);
let later = b.now + Duration::seconds(10);
assert!(verify_member_map(&b.members, &b.policy, &TargetSpec::default(), None, later).is_err());
}
#[test]
fn untrusted_release_signer_fails() {
let mut b = valid_bundle();
// Policy no longer trusts the actual release signer.
b.policy.trusted_release_signers = vec![self_signed("someone-else").fingerprint];
assert!(!ok(&b));
}
#[test]
fn ca_chained_bundle_verifies_and_wrong_root_fails() {
let root = ca("Holger Test Root CA");
let reviewer = issue_leaf("security-reviewer", &root);
let owner = issue_leaf("system-owner", &root);
let release = issue_leaf("release-signer", &root);
let now = chrono::Utc::now();
let prep = base_preparation(9, "bundle-0009", now);
let mut members = prep.build().unwrap().members;
crate::finalize::add_approval(
&mut members,
"security-reviewer",
&reviewer.cert_der,
&reviewer.key_pkcs8,
None,
now,
)
.unwrap();
crate::finalize::add_approval(
&mut members,
"system-owner",
&owner.cert_der,
&owner.key_pkcs8,
None,
now,
)
.unwrap();
let mut policy = TrustPolicy {
schema_version: POLICY_SCHEMA.into(),
trust_mode: Some(TrustMode::RootedChain),
trusted_roots: vec![hex(&root.der)],
certificate_validity: CertificateValidity::Enforce,
approved_signers: vec![
ApprovedSigner {
fingerprint: reviewer.fingerprint.clone(),
subject: None,
roles: vec!["security-reviewer".into()],
},
ApprovedSigner {
fingerprint: owner.fingerprint.clone(),
subject: None,
roles: vec!["system-owner".into()],
},
],
required_approval_roles: vec!["security-reviewer".into(), "system-owner".into()],
minimum_distinct_approvers: 2,
trusted_release_signers: vec![release.fingerprint.clone()],
release_signer_must_be_distinct: true,
maximum_bundle_age_secs: None,
require_expiry: true,
on_expired_metadata: Some(ExpiredPosture::ReadOnly),
require_sbom: true,
require_vex: false,
require_scan: false,
allowed_products: vec![],
allowed_models: vec![],
};
finalize(
&mut members,
&policy,
&release.cert_der,
&release.key_pkcs8,
now,
)
.unwrap();
assert!(verify_member_map(&members, &policy, &TargetSpec::default(), None, now).is_ok());
// A different, unrelated root must break the chain check.
let other = ca("Some Other CA");
policy.trusted_roots = vec![hex(&other.der)];
assert!(verify_member_map(&members, &policy, &TargetSpec::default(), None, now).is_err());
}
// ── core.json's declared content list binds to the actual members ───────────
//
// The approvals sign `candidate_digest`, which is sha256(core.json), and
// core.json declares every payload and evidence file by path, size, sha256 and
// blake3. Until the verifier compared that list to the bundle, members were
// checked only against the RELEASE MANIFEST — a document the release signer
// writes. So one key could swap a file, rebuild the manifest over the new
// bytes, and both approval signatures would still verify: the two-person rule
// intact on paper, over a content list nobody checked.
//
// Each test below rebuilds the release manifest itself rather than calling
// `finalize`, because `finalize`'s producer-side preflight already refuses a
// core/member disagreement. That preflight is exactly what an attacker holding
// the key does not run.
/// Re-manifest and re-sign a tampered member map with the real release key,
/// leaving core.json (and therefore the approvals) untouched.
fn remanifest_with_release_key(b: &mut BuiltBundle) {
b.members.remove(RELEASE_MANIFEST_JSON);
b.members.remove(RELEASE_SIG);
let manifest = ReleaseManifest::build(&b.members, &b.bundle_id, &b.candidate_digest);
let manifest_bytes = manifest.to_json_bytes().unwrap();
let sig = sign_pkcs8(&b.release.key_pkcs8, &manifest_bytes).unwrap();
b.members
.insert(RELEASE_MANIFEST_JSON.to_string(), manifest_bytes);
b.members.insert(RELEASE_SIG.to_string(), sig);
// The bundle must now be manifest-consistent, or the refusal below would be
// proving the old check rather than the new one.
let m = ReleaseManifest::from_json_bytes(&b.members[RELEASE_MANIFEST_JSON]).unwrap();
m.verify_members(&b.members)
.expect("the tampered bundle must agree with its own release manifest");
}
#[test]
fn a_payload_swapped_after_approval_fails_even_when_the_release_manifest_agrees() {
let mut b = valid_bundle();
// Same length as "firmware-v1", so the size check cannot be what catches it.
b.members
.insert("payload/firmware.bin".into(), b"firmware-v2".to_vec());
remanifest_with_release_key(&mut b);
let err = verify_member_map(&b.members, &b.policy, &TargetSpec::default(), None, b.now)
.expect_err("a payload core.json does not declare must be refused");
match err {
BundleError::DigestMismatch { path, which } => {
assert_eq!(path, "payload/firmware.bin");
assert_eq!(which, "sha256");
}
other => panic!("expected a digest mismatch on the payload, got {other:?}"),
}
}
#[test]
fn a_truncated_payload_is_refused_on_size_before_it_is_hashed() {
let mut b = valid_bundle();
b.members
.insert("payload/firmware.bin".into(), b"firmware".to_vec());
remanifest_with_release_key(&mut b);
let err = verify_member_map(&b.members, &b.policy, &TargetSpec::default(), None, b.now)
.expect_err("a truncated payload must be refused");
match err {
BundleError::DigestMismatch { path, which } => {
assert_eq!(path, "payload/firmware.bin");
assert_eq!(which, "size");
}
other => panic!("expected a size mismatch on the payload, got {other:?}"),
}
}
#[test]
fn an_sbom_swapped_after_approval_fails_even_when_the_release_manifest_agrees() {
let mut b = valid_bundle();
let original = b.members["evidence/sbom/app.cdx.json"].len();
let forged = br#"{"bomFormat":"CycloneDX","specVersion":"1.4"}"#.to_vec();
assert_eq!(
forged.len(),
original,
"keep the size equal so the digest is what refuses it"
);
b.members
.insert("evidence/sbom/app.cdx.json".into(), forged);
remanifest_with_release_key(&mut b);
let err = verify_member_map(&b.members, &b.policy, &TargetSpec::default(), None, b.now)
.expect_err("an SBOM core.json does not declare must be refused");
match err {
BundleError::DigestMismatch { path, which } => {
assert_eq!(path, "evidence/sbom/app.cdx.json");
assert_eq!(which, "sha256");
}
other => panic!("expected a digest mismatch on the SBOM, got {other:?}"),
}
}
#[test]
fn a_payload_deleted_after_approval_is_refused_by_name() {
let mut b = valid_bundle();
b.members.remove("payload/firmware.bin");
remanifest_with_release_key(&mut b);
let err = verify_member_map(&b.members, &b.policy, &TargetSpec::default(), None, b.now)
.expect_err("a payload core.json declares but the bundle lacks must be refused");
match err {
BundleError::MissingMember(path) => assert_eq!(path, "payload/firmware.bin"),
other => panic!("expected a missing member, got {other:?}"),
}
}
/// **A payload ADDED after approval must be refused too.**
///
/// Step 4b walks `core.payloads` and `core.evidence` and checks each declaration
/// against the members. That direction alone is not enough. The release manifest
/// is built FROM the members, so a member added after approval appears in it and
/// passes the manifest check; `core.json` is untouched, so `candidate_digest` is
/// untouched and both approval signatures still verify. Nothing then asks the
/// opposite question — is every payload member actually declared? — and an
/// undeclared file ships inside a bundle two people signed off on.
///
/// This is the same defect the one-directional check was written to close, seen
/// from the other side: approvals cover a content LIST, and a list is only
/// binding if it is both complete and exact.
#[test]
fn a_payload_added_after_approval_is_refused_even_though_the_manifest_lists_it() {
let mut b = valid_bundle();
b.members
.insert("payload/stowaway.bin".into(), b"not in core.json".to_vec());
remanifest_with_release_key(&mut b);
let err = verify_member_map(&b.members, &b.policy, &TargetSpec::default(), None, b.now)
.expect_err("a payload core.json does not declare must be refused");
let text = format!("{err:?}");
assert!(
text.contains("stowaway"),
"the refusal must name the undeclared member: {text}"
);
}
/// The same from the evidence side: an extra `evidence/` file rides in under the
/// approvers' signatures unless the reverse direction is checked.
#[test]
fn an_evidence_file_added_after_approval_is_refused() {
let mut b = valid_bundle();
b.members.insert(
"evidence/scan-report.json".into(),
br#"{"verdict":"clean"}"#.to_vec(),
);
remanifest_with_release_key(&mut b);
assert!(
verify_member_map(&b.members, &b.policy, &TargetSpec::default(), None, b.now).is_err(),
"forged evidence must not ride in under someone else's approval"
);
}