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/bomb.rs

//! Decompression-bomb and load-order tests.
//!
//! The property under test is an ORDER, not a size: the expensive extraction must
//! happen AFTER the release signature is verified, never before. Every test here
//! is therefore a differential between the two loaders — `load_bundle`, which
//! extracts first and authenticates never, and `load_bundle_verified`, which
//! authenticates a few MiB of control members and only then decompresses anything.
//! A test that merely asserted "the verified loader returns an error" would pass
//! just as happily with the gate deleted; what pins the fix is asserting WHICH
//! error, because the wrong one proves the payload was reached.

use std::fs;

use super::testkit::*;
use crate::container::{load_bundle, load_bundle_verified, pack_bundle};
use crate::error::BundleError;
use crate::schema::{
    EXPANSION_RATIO_FLOOR_BYTES, MAX_BUNDLE_FILE_BYTES, MAX_CONTROL_BYTES, RELEASE_SIG,
    SIGNATURES_PREFIX,
};

/// A marker long and odd enough that finding it in the packed archive is
/// unambiguous. Small members are stored with little or no compression, so the
/// literal survives into the archive and its stored bytes can be corrupted.
const MARKER: &[u8] = b"PAYLOAD-MARKER-MUST-NOT-BE-EXTRACTED";
const STOWAWAY_MARKER: &[u8] = b"STOWAWAY-MARKER-MUST-NOT-BE-EXTRACTED";

/// Flip a bit inside a member's STORED bytes in a packed archive, so that znippy's
/// per-chunk checksum makes extracting that ONE member fatal. Panics if the marker
/// is not present — a silent fallback would make the differentials below prove
/// nothing.
///
/// Corrupting the bytes rather than deleting the member is what turns "was it
/// extracted?" — normally invisible from outside — into an observable verdict: a
/// loader that touches the member says so, and a loader that refuses it earlier
/// returns its own error instead.
fn corrupt_stored_member(path: &std::path::Path, marker: &[u8]) {
    let mut bytes = fs::read(path).unwrap();
    let pos = bytes
        .windows(marker.len())
        .position(|w| w == marker)
        .expect("marker must be present in the packed archive");
    bytes[pos + 5] ^= 0x01;
    fs::write(path, &bytes).unwrap();
}

#[test]
fn a_bundle_whose_release_signature_is_invalid_is_refused_before_its_payload_is_extracted() {
    let mut b = valid_bundle_with_payload(MARKER.to_vec());
    // Break the release signature. `release.sig` is one of the three members the
    // release manifest cannot cover, so the archive itself stays self-consistent
    // and nothing but the signature check can object to it.
    b.members.get_mut(RELEASE_SIG).unwrap()[0] ^= 0x01;

    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("bundle.holger.znippy");
    pack_bundle(&b.members, &out).unwrap();
    corrupt_stored_member(&out, MARKER);

    // The payload is now a landmine: ANY loader that extracts it dies on it.
    // `load_bundle` extracts first and authenticates never, so it steps on it —
    // which is what makes this a differential and not a coincidence.
    match load_bundle(&out) {
        Err(BundleError::Container(_)) => {}
        other => panic!(
            "the unauthenticated loader must reach — and fail on — the corrupt payload, got {other:?}"
        ),
    }

    // The verified loader must never get there: the signature is checked over a
    // few MiB of control members, and the refusal is the SIGNATURE's, not the
    // payload's. If the gate were removed this would be `Container` instead.
    match load_bundle_verified(&out, &b.policy) {
        Err(BundleError::InvalidReleaseSignature) => {}
        other => panic!(
            "an unsigned bundle must be refused on its signature, before any payload is \
             decompressed — got {other:?}"
        ),
    }
}

#[test]
fn a_member_the_signed_manifest_does_not_name_is_refused_without_being_extracted() {
    let mut b = valid_bundle_with_payload(MARKER.to_vec());
    // A stowaway: in the archive, absent from the signed release manifest. The
    // release signature stays valid — the manifest it covers is untouched, so the
    // gate passes and this member's fate is decided in phase two.
    b.members
        .insert("payload/stowaway.bin".into(), STOWAWAY_MARKER.to_vec());

    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("bundle.holger.znippy");
    pack_bundle(&b.members, &out).unwrap();
    // Make the stowaway a landmine. Refusing it by NAME and refusing it after
    // decompressing it are two different behaviours, and this is what tells them
    // apart: only a loader that never hands it to a decompressor survives.
    corrupt_stored_member(&out, STOWAWAY_MARKER);

    // The unauthenticated loader decompresses whatever the archive lists, so it
    // steps on it.
    match load_bundle(&out) {
        Err(BundleError::Container(msg)) => assert!(
            msg.contains("stowaway"),
            "the unauthenticated loader must die on the stowaway itself: {msg:?}"
        ),
        other => {
            panic!("the differential needs extracting the stowaway to be fatal, got {other:?}")
        }
    }

    // The verified loader refuses it BY NAME against the now-signed manifest,
    // before a decompressor is handed the member at all.
    match load_bundle_verified(&out, &b.policy) {
        Err(BundleError::UnreferencedMember(path)) => {
            assert_eq!(path, "payload/stowaway.bin");
        }
        other => panic!("a member absent from the signed manifest must be refused, got {other:?}"),
    }
}

#[test]
fn an_archive_that_expands_far_beyond_its_own_size_is_refused_as_a_bomb() {
    // Just over the ratio guard's floor, all zeros: it compresses to a few KB, so
    // the expansion ratio is thousands to one — the bomb SHAPE.
    let payload_len = (EXPANSION_RATIO_FLOOR_BYTES + 4 * 1024 * 1024) as usize;
    let b = valid_bundle_with_payload(vec![0u8; payload_len]);

    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("bomb.holger.znippy");
    pack_bundle(&b.members, &out).unwrap();

    let on_disk = fs::metadata(&out).unwrap().len();
    assert!(
        on_disk * 100 < payload_len as u64,
        "the test payload must actually compress to a bomb shape (archive {on_disk} B, \
         expands to {payload_len} B)"
    );

    match load_bundle(&out) {
        Err(BundleError::LimitExceeded(msg)) => {
            assert!(
                msg.contains("decompression bomb"),
                "the refusal must name the bomb, got {msg:?}"
            );
        }
        // Summarised, never `{other:?}`: on failure that value is the whole
        // 68 MiB member map, and a panic message must not become the bomb.
        Ok(map) => panic!(
            "a {payload_len}-byte expansion from {on_disk} bytes on disk must be refused, \
             but {} members loaded",
            map.len()
        ),
        Err(e) => panic!("expected a bomb refusal, got {e}"),
    }
}

#[test]
fn a_legitimately_large_but_poorly_compressing_bundle_is_not_mistaken_for_a_bomb() {
    // The other half of the ratio guard, and the half that decides whether it can
    // ship: pseudo-random bytes past the floor compress hardly at all, so the
    // ratio stays near 1 and a real firmware bundle must load unharmed.
    let payload_len = (EXPANSION_RATIO_FLOOR_BYTES + 4 * 1024 * 1024) as usize;
    let mut payload = Vec::with_capacity(payload_len);
    let mut x: u64 = 0x2545_F491_4F6C_DD1D;
    while payload.len() < payload_len {
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
        payload.extend_from_slice(&x.to_le_bytes());
    }
    payload.truncate(payload_len);
    let b = valid_bundle_with_payload(payload);

    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("firmware.holger.znippy");
    pack_bundle(&b.members, &out).unwrap();

    // BOTH loaders must accept it, and `load_bundle` is the one that matters here:
    // it is the loader carrying the expansion-ratio guard, so it is the only place
    // a mis-calibrated ratio could turn a real firmware bundle into a refusal.
    let loose = match load_bundle(&out) {
        Ok(m) => m,
        Err(e) => panic!("the ratio guard must not refuse incompressible payload: {e}"),
    };
    assert_eq!(loose["payload/firmware.bin"].len(), payload_len);

    let loaded = load_bundle_verified(&out, &b.policy)
        .map_err(|e| format!("{e}"))
        .expect("a large, incompressible bundle is not a bomb and must load");
    assert_eq!(loaded["payload/firmware.bin"].len(), payload_len);
}

#[test]
fn an_oversized_archive_is_refused_by_stat_before_it_is_ever_opened() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("huge.holger.znippy");
    // A sparse file: the length the `stat` door reads is real, the disk cost is
    // not. The bytes are not a znippy archive at all, which is the point — a
    // refusal that names the SIZE proves nothing tried to parse it.
    let f = fs::File::create(&out).unwrap();
    f.set_len(MAX_BUNDLE_FILE_BYTES + 1).unwrap();
    drop(f);

    match load_bundle(&out) {
        Err(BundleError::LimitExceeded(msg)) => {
            assert!(
                msg.contains("bundle file size"),
                "the refusal must come from the stat door, not the parser: {msg:?}"
            );
        }
        other => panic!("an archive over the file cap must be refused unopened, got {other:?}"),
    }
}

#[test]
fn a_control_member_over_the_control_cap_cannot_bomb_the_pre_signature_phase() {
    let mut b = valid_bundle_with_payload(MARKER.to_vec());
    // A bomb disguised as a signature: phase one reads everything under
    // `signatures/`, so that prefix is the one an attacker would aim at. Its own
    // cap is what keeps phase one from becoming the thing it exists to prevent.
    b.members.insert(
        format!("{SIGNATURES_PREFIX}bloat.bin"),
        vec![0u8; (MAX_CONTROL_BYTES + 1) as usize],
    );

    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("fat-control.holger.znippy");
    pack_bundle(&b.members, &out).unwrap();

    match load_bundle_verified(&out, &b.policy) {
        Err(BundleError::LimitExceeded(msg)) => {
            assert!(
                msg.contains("control member"),
                "the refusal must come from the control-plane cap: {msg:?}"
            );
        }
        other => panic!("an oversized control member must be refused, got {other:?}"),
    }
}