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/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"
    );
}