Gunnar

nordisk/heimdall

guests/verify-bundle/tests/tampering.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Every tampering of a real bundle is REFUSED with Holger's own refusal code,
//! and Holger's verifier refuses the same archive too (the differential oracle).
//!
//! Two attackers: one who can only change bytes (no key at all), and one insider
//! who holds the RELEASE key but not the approvers' keys ([`Fixture::resign`]).

mod common;
use common::*;

use holger_errcode as ec;

/// The guest refuses with `code`, and Holger's verifier refuses the same bytes.
#[track_caller]
fn both_refuse(fx: &Fixture, code: &ec::ErrCode, needle: &str) {
    let archive = fx.pack();
    let o = verify_bundle(&archive, &fx.params());
    assert_refused(&o, code.code, needle);
    let h = fx.holger(&archive);
    assert!(
        h.is_err(),
        "Holger ACCEPTED what the guest refused ({needle})"
    );
}

fn flip(b: &mut [u8], i: usize) {
    b[i] ^= 0x01;
}

// ------------------------------------------------- byte-level, no key

#[test]
fn a_flipped_bit_in_the_payload_is_refused_as_a_digest_mismatch() {
    let mut fx = good();
    flip(fx.members.get_mut("payload/firmware.bin").unwrap(), 0);
    both_refuse(&fx, &ec::UPDATE_DIGEST_MISMATCH, "payload/firmware.bin");
}

#[test]
fn a_flipped_bit_in_an_evidence_file_is_refused() {
    let mut fx = good();
    flip(fx.members.get_mut("evidence/sbom/app.cdx.json").unwrap(), 3);
    both_refuse(
        &fx,
        &ec::UPDATE_DIGEST_MISMATCH,
        "evidence/sbom/app.cdx.json",
    );
}

#[test]
fn a_changed_core_json_is_refused_before_its_signature_is_believed() {
    let mut fx = good();
    let c = fx.members.get_mut("core.json").unwrap();
    let s = String::from_utf8(c.clone())
        .unwrap()
        .replace("ExampleDrive", "EvilDrive");
    *c = s.into_bytes();
    both_refuse(&fx, &ec::UPDATE_DIGEST_MISMATCH, "core.json");
}

#[test]
fn a_flipped_bit_in_the_release_signature_is_refused() {
    let mut fx = good();
    flip(fx.members.get_mut(RELEASE_SIG).unwrap(), 10);
    both_refuse(
        &fx,
        &ec::UPDATE_RELEASE_SIGNATURE_INVALID,
        "release signature",
    );
}

#[test]
fn a_truncated_release_signature_is_refused() {
    let mut fx = good();
    fx.members.get_mut(RELEASE_SIG).unwrap().truncate(63);
    both_refuse(
        &fx,
        &ec::UPDATE_RELEASE_SIGNATURE_INVALID,
        "release signature",
    );
}

#[test]
fn an_empty_release_signature_is_refused() {
    let mut fx = good();
    fx.members.insert(RELEASE_SIG.into(), Vec::new());
    both_refuse(
        &fx,
        &ec::UPDATE_RELEASE_SIGNATURE_INVALID,
        "release signature",
    );
}

#[test]
fn a_missing_release_signature_is_refused() {
    let mut fx = good();
    fx.members.remove(RELEASE_SIG);
    both_refuse(&fx, &ec::UPDATE_MEMBER_MISSING, "release.sig");
}

#[test]
fn a_missing_release_manifest_is_refused() {
    let mut fx = good();
    fx.members.remove(RELEASE_MANIFEST);
    both_refuse(&fx, &ec::UPDATE_MEMBER_MISSING, "release-manifest.json");
}

#[test]
fn a_release_certificate_swapped_for_a_stranger_is_refused() {
    let mut fx = good();
    fx.members
        .insert(RELEASE_CRT.into(), self_signed("stranger").cert_der);
    both_refuse(
        &fx,
        &ec::UPDATE_RELEASE_SIGNATURE_INVALID,
        "release signature",
    );
}

#[test]
fn a_release_certificate_that_is_garbage_is_refused() {
    let mut fx = good();
    fx.members
        .insert(RELEASE_CRT.into(), b"not a certificate".to_vec());
    both_refuse(&fx, &ec::UPDATE_CERTIFICATE, "certificate");
}

#[test]
fn a_flipped_bit_in_the_release_manifest_is_refused() {
    let mut fx = good();
    let m = fx.members.get_mut(RELEASE_MANIFEST).unwrap();
    let s = String::from_utf8(m.clone()).unwrap();
    // change one hex digit of a recorded digest
    let i = s.find("\"sha256\": \"").unwrap() + 11;
    let mut b = s.into_bytes();
    b[i] = if b[i] == b'0' { b'1' } else { b'0' };
    *m = b;
    both_refuse(&fx, &ec::UPDATE_DIGEST_MISMATCH, "digest mismatch");
}

#[test]
fn an_injected_stowaway_payload_is_refused_by_name_before_it_is_read() {
    let mut fx = good();
    fx.members
        .insert("payload/stowaway.sh".into(), b"rm -rf /".to_vec());
    both_refuse(&fx, &ec::UPDATE_MEMBER_UNREFERENCED, "payload/stowaway.sh");
}

#[test]
fn an_injected_control_member_is_refused() {
    let mut fx = good();
    fx.members
        .insert("signatures/extra.sig".into(), vec![0; 64]);
    both_refuse(&fx, &ec::UPDATE_MEMBER_UNREFERENCED, "signatures/extra.sig");
}

#[test]
fn a_removed_payload_is_refused_as_missing() {
    let mut fx = good();
    fx.members.remove("payload/firmware.bin");
    both_refuse(&fx, &ec::UPDATE_MEMBER_MISSING, "payload/firmware.bin");
}

#[test]
fn a_removed_approval_is_refused() {
    let mut fx = good();
    let k = fx
        .members
        .keys()
        .find(|k| k.ends_with(".approval.json"))
        .unwrap()
        .clone();
    fx.members.remove(&k);
    // The gate (control plane) counts approvers before it sweeps for missing
    // members, exactly as Holger's `load_bundle_verified` does.
    both_refuse(&fx, &ec::UPDATE_TWO_PERSON_RULE, "1 distinct approver(s)");
}

#[test]
fn a_payload_grown_by_one_byte_is_refused() {
    let mut fx = good();
    fx.members
        .get_mut("payload/firmware.bin")
        .unwrap()
        .push(b'!');
    both_refuse(&fx, &ec::UPDATE_DIGEST_MISMATCH, "payload/firmware.bin");
}

#[test]
fn a_payload_truncated_by_one_byte_is_refused() {
    let mut fx = good();
    fx.members.get_mut("payload/firmware.bin").unwrap().pop();
    both_refuse(&fx, &ec::UPDATE_DIGEST_MISMATCH, "payload/firmware.bin");
}

#[test]
fn an_archive_with_no_members_at_all_is_refused() {
    let fx = good();
    let empty = zwriter::RawArchive::from_members(&MemberMap::new(), 1024).encode();
    let o = verify_bundle(&empty, &fx.params());
    assert_refused(&o, ec::UPDATE_MEMBER_MISSING.code, "release-manifest.json");
}

// --------------------------------------- the insider with the release key

#[test]
fn a_release_signer_who_swaps_the_payload_is_refused_by_the_approved_content_list() {
    // The manifest and the members agree perfectly; core.json (what the two
    // approvers signed) still names the old payload.
    let mut fx = good();
    fx.members
        .insert("payload/firmware.bin".into(), b"evil-firmware".to_vec());
    fx.resign();
    both_refuse(&fx, &ec::UPDATE_DIGEST_MISMATCH, "payload/firmware.bin");
}

#[test]
fn a_release_signer_who_adds_an_undeclared_payload_is_refused() {
    let mut fx = good();
    fx.members
        .insert("payload/backdoor.bin".into(), b"x".to_vec());
    fx.resign();
    both_refuse(&fx, &ec::UPDATE_MEMBER_UNREFERENCED, "payload/backdoor.bin");
}

#[test]
fn a_release_signer_who_rewrites_core_json_breaks_every_approval() {
    let mut fx = good();
    let c = fx.members.get_mut("core.json").unwrap();
    *c = String::from_utf8(c.clone())
        .unwrap()
        .replace("1.2.3", "6.6.6")
        .into_bytes();
    fx.resign();
    // manifest.json (the envelope) still names the old core digest, so the
    // first thing to break is either the envelope or the approvals' digest.
    let o = verify_bundle(&fx.pack(), &fx.params());
    assert!(!o.is_accepted());
    assert!(fx.holger(&fx.pack()).is_err());
}

#[test]
fn a_release_signer_who_drops_an_approval_fails_the_two_person_rule() {
    let mut fx = good();
    let gone: Vec<String> = fx
        .members
        .keys()
        .filter(|k| k.contains("system-owner"))
        .cloned()
        .collect();
    for k in gone {
        fx.members.remove(&k);
    }
    fx.resign();
    both_refuse(
        &fx,
        &ec::UPDATE_TWO_PERSON_RULE,
        "1 distinct approver(s), need 2",
    );
}

#[test]
fn a_release_signer_who_forges_an_approval_statement_is_refused_on_its_signature() {
    let mut fx = good();
    let k = fx
        .members
        .keys()
        .find(|k| k.ends_with(".approval.json"))
        .unwrap()
        .clone();
    let s = String::from_utf8(fx.members[&k].clone()).unwrap();
    fx.members
        .insert(k, s.replace("\"approve\"", "\"approve\" ").into_bytes());
    fx.resign();
    both_refuse(
        &fx,
        &ec::UPDATE_APPROVAL_SIGNATURE_INVALID,
        "approval signature invalid",
    );
}

#[test]
fn a_release_signer_who_approves_under_a_strangers_certificate_is_untrusted() {
    let mut fx = good();
    let stranger = self_signed("stranger");
    holger_update_bundle::finalize::add_approval(
        &mut fx.members,
        "security-reviewer",
        &stranger.cert_der,
        &stranger.key_pkcs8,
        None,
        fx.now,
    )
    .unwrap();
    fx.resign();
    both_refuse(&fx, &ec::UPDATE_UNTRUSTED_SIGNER, &stranger.fingerprint);
}

#[test]
fn a_release_signer_who_also_approves_breaks_the_distinctness_rule() {
    let mut fx = good();
    // the release key is ALSO listed as a reviewer, and approves
    fx.policy.approved_signers[0].fingerprint = fx.release.fingerprint.clone();
    let keys: Vec<String> = fx
        .members
        .keys()
        .filter(|k| k.contains("security-reviewer"))
        .cloned()
        .collect();
    for k in keys {
        fx.members.remove(&k);
    }
    let rel = fx.release.clone();
    holger_update_bundle::finalize::add_approval(
        &mut fx.members,
        "security-reviewer",
        &rel.cert_der,
        &rel.key_pkcs8,
        None,
        fx.now,
    )
    .unwrap();
    fx.resign();
    both_refuse(
        &fx,
        &ec::UPDATE_TWO_PERSON_RULE,
        "release signer must be distinct",
    );
}

#[test]
fn one_certificate_cannot_hold_both_required_roles() {
    let mut fx = good();
    let owner_fp = fx.owner.fingerprint.clone();
    fx.policy.approved_signers[1].roles = vec!["system-owner".into(), "security-reviewer".into()];
    // drop the real reviewer, let the owner approve twice under both roles
    let keys: Vec<String> = fx
        .members
        .keys()
        .filter(|k| k.contains("security-reviewer"))
        .cloned()
        .collect();
    for k in keys {
        fx.members.remove(&k);
    }
    let owner = fx.owner.clone();
    holger_update_bundle::finalize::add_approval(
        &mut fx.members,
        "security-reviewer",
        &owner.cert_der,
        &owner.key_pkcs8,
        None,
        fx.now,
    )
    .unwrap();
    fx.policy.minimum_distinct_approvers = 1;
    fx.resign();
    let o = verify_bundle(&fx.pack(), &fx.params());
    assert_refused(
        &o,
        ec::UPDATE_ROLE_MISSING.code,
        "required approval role missing",
    );
    assert!(fx.holger(&fx.pack()).is_err());
    let _ = owner_fp;
}

#[test]
fn a_release_signer_who_lies_about_the_candidate_digest_is_refused() {
    let mut fx = good();
    let rel = fx.release.clone();
    fx.resign_with_digest(&rel, &"0".repeat(64));
    both_refuse(
        &fx,
        &ec::UPDATE_CANDIDATE_DIGEST_MISMATCH,
        "candidate digest",
    );
}

#[test]
fn a_bundle_released_over_a_block_verdict_is_refused_though_every_signature_is_genuine() {
    let spec = Spec {
        bundle_id: "bundle-blocked".into(),
        ..Spec::default()
    };
    let now = t0();
    let mut prep = preparation(&spec, now);
    prep.policy_result.verdict = "block".into();
    let cand = prep.build().unwrap();
    let mut fx = good();
    fx.members = cand.members;
    fx.bundle_id = spec.bundle_id.clone();
    let (r, o) = (fx.reviewer.clone(), fx.owner.clone());
    holger_update_bundle::finalize::add_approval(
        &mut fx.members,
        "security-reviewer",
        &r.cert_der,
        &r.key_pkcs8,
        None,
        now,
    )
    .unwrap();
    holger_update_bundle::finalize::add_approval(
        &mut fx.members,
        "system-owner",
        &o.cert_der,
        &o.key_pkcs8,
        None,
        now,
    )
    .unwrap();
    fx.resign();
    both_refuse(&fx, &ec::UPDATE_VERDICT_NOT_PASS, "\"block\"");
}

#[test]
fn a_release_signed_by_an_unlisted_key_is_an_untrusted_release_signer() {
    let mut fx = good();
    let mallory = self_signed("mallory");
    fx.resign_with(&mallory);
    both_refuse(
        &fx,
        &ec::UPDATE_UNTRUSTED_RELEASE_SIGNER,
        &mallory.fingerprint,
    );
}

#[test]
fn a_rooted_chain_leaf_from_a_foreign_ca_is_untrusted() {
    let mut fx = build(Spec {
        rooted: true,
        ..Spec::default()
    });
    let foreign = ca("foreign-ca");
    let mallory = leaf("release-signer", &foreign);
    fx.policy
        .trusted_release_signers
        .push(mallory.fingerprint.clone());
    fx.resign_with(&mallory);
    both_refuse(
        &fx,
        &ec::UPDATE_UNTRUSTED_RELEASE_SIGNER,
        &mallory.fingerprint,
    );
}

#[test]
fn a_leaf_issued_by_a_trusted_root_that_is_not_a_ca_is_untrusted() {
    let mut fx = build(Spec {
        rooted: true,
        ..Spec::default()
    });
    let fake_root = non_ca_root("not-a-ca");
    fx.policy.trusted_roots.push(hex(&fake_root.der));
    let mallory = leaf("release-signer", &fake_root);
    fx.policy
        .trusted_release_signers
        .push(mallory.fingerprint.clone());
    fx.resign_with(&mallory);
    both_refuse(
        &fx,
        &ec::UPDATE_UNTRUSTED_RELEASE_SIGNER,
        &mallory.fingerprint,
    );
}

#[test]
fn an_envelope_that_disagrees_with_core_json_is_refused() {
    let mut fx = good();
    let m = fx.members.get_mut("manifest.json").unwrap();
    *m = String::from_utf8(m.clone())
        .unwrap()
        .replace("\"sequence\": 5", "\"sequence\": 6")
        .into_bytes();
    fx.resign();
    both_refuse(&fx, &ec::UPDATE_ENVELOPE_MISMATCH, "manifest.json");
}