Gunnar

nordisk/heimdall

crates/heimdall-wasm/tests/signatures.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Guests are signed like holger's wasm plugins (`<file>.wasm.p7s`, a detached
//! CMS over the module's BLAKE3-256, plus the `<file>.wasm.blake3` pin) and are
//! verified BEFORE they are compiled. Unsigned, tampered, wrong-key and badly
//! pinned guests never load. Heimdall is a defensive sandbox.

mod common;
use common::*;
use guest_host::verify::sign::{dev, sign_module};
use heimdall_wasm::{Host, HostConfig, HostError, LoadRefusal, TrustFailure};

fn untrusted(r: Result<heimdall_wasm::Guest, LoadRefusal>) -> (TrustFailure, String) {
    match r {
        Err(LoadRefusal::Untrusted { failure, detail }) => (failure, detail),
        Err(other) => panic!("refused for another reason: {other}"),
        Ok(_) => panic!("an untrusted guest loaded"),
    }
}

#[test]
fn a_signed_guest_loads() {
    let kit = Kit::new("signed");
    let p = kit.place("good", &component(&good_wat()));
    assert!(kit.host().load(&p).is_ok());
}

#[test]
fn a_host_without_trust_roots_cannot_be_built() {
    assert!(matches!(
        Host::new(HostConfig::new([])),
        Err(HostError::NoTrustRoots)
    ));
}

#[test]
fn an_unsigned_guest_is_refused() {
    let kit = Kit::new("unsigned");
    let p = kit.place("good", &component(&good_wat()));
    std::fs::remove_file(sidecar(&p, "p7s")).unwrap();
    let (f, detail) = untrusted(kit.host().load(&p));
    assert_eq!(f, TrustFailure::Unsigned);
    assert!(
        detail.contains("good.wasm.p7s"),
        "the refusal does not name the missing sidecar: {detail}"
    );
}

#[test]
fn a_tampered_guest_with_a_matching_blake3_pin_fails_the_signature() {
    let kit = Kit::new("tampered");
    let p = kit.place("good", &component(&good_wat()));
    // Swap in another guest and re-pin its blake3: only the signature can tell.
    let other = component(&Wat::new("(call $ok (i32.const 0) (i32.const 0))").text());
    std::fs::write(&p, &other).unwrap();
    std::fs::write(
        sidecar(&p, "blake3"),
        blake3::hash(&other).to_hex().as_str(),
    )
    .unwrap();
    let (f, _) = untrusted(kit.host().load(&p));
    assert_eq!(f, TrustFailure::SignatureInvalid);
}

#[test]
fn a_single_flipped_bit_in_the_module_is_refused() {
    let kit = Kit::new("bitflip");
    let bytes = component(&good_wat());
    let p = kit.place("good", &bytes);
    let mut flipped = bytes.clone();
    let i = flipped.len() / 2;
    flipped[i] ^= 1;
    std::fs::write(&p, &flipped).unwrap();
    assert!(matches!(
        kit.host().load(&p),
        Err(LoadRefusal::DigestMismatch { .. })
    ));
}

#[test]
fn a_guest_signed_under_another_root_is_refused() {
    let kit = Kit::new("wrongkey");
    let bytes = component(&good_wat());
    let p = kit.place("good", &bytes);
    let (k, der) = dev::mint_ca("somebody-else").unwrap();
    let stranger = dev::new_ed25519_signer(&k, &der, "stranger").unwrap();
    std::fs::write(sidecar(&p, "p7s"), sign_module(&bytes, &stranger).unwrap()).unwrap();
    let (f, _) = untrusted(kit.host().load(&p));
    assert_eq!(f, TrustFailure::SignatureInvalid);
}

#[test]
fn a_garbage_signature_file_is_refused() {
    let kit = Kit::new("garbage-sig");
    let p = kit.place("good", &component(&good_wat()));
    std::fs::write(sidecar(&p, "p7s"), b"not a CMS SignedData").unwrap();
    assert_eq!(
        untrusted(kit.host().load(&p)).0,
        TrustFailure::SignatureInvalid
    );
}

#[test]
fn an_empty_signature_file_is_refused() {
    let kit = Kit::new("empty-sig");
    let p = kit.place("good", &component(&good_wat()));
    std::fs::write(sidecar(&p, "p7s"), b"").unwrap();
    assert_eq!(
        untrusted(kit.host().load(&p)).0,
        TrustFailure::SignatureInvalid
    );
}

#[test]
fn a_signature_for_another_guest_is_refused() {
    let kit = Kit::new("swapped-sig");
    let a = kit.place("a", &component(&good_wat()));
    let b = kit.place(
        "b",
        &component(&Wat::new("(call $ok (i32.const 0) (i32.const 0))").text()),
    );
    std::fs::copy(sidecar(&b, "p7s"), sidecar(&a, "p7s")).unwrap();
    assert_eq!(
        untrusted(kit.host().load(&a)).0,
        TrustFailure::SignatureInvalid
    );
}

#[test]
fn an_unreadable_trust_root_is_refused_not_ignored() {
    let kit = Kit::new("noroot");
    let p = kit.place("good", &component(&good_wat()));
    let host = Host::new(HostConfig::new([kit.dir.join("missing-root.der")])).unwrap();
    assert_eq!(untrusted(host.load(&p)).0, TrustFailure::RootsUnreadable);
}

#[test]
fn a_trust_root_that_is_not_a_certificate_is_refused() {
    let kit = Kit::new("junkroot");
    let p = kit.place("good", &component(&good_wat()));
    let junk = kit.dir.join("junk.der");
    std::fs::write(&junk, b"junk").unwrap();
    let host = Host::new(HostConfig::new([junk])).unwrap();
    assert_eq!(untrusted(host.load(&p)).0, TrustFailure::RootsUnreadable);
}

#[test]
fn one_good_root_among_several_is_enough() {
    let kit = Kit::new("tworoots");
    let p = kit.place("good", &component(&good_wat()));
    let (_, der) = dev::mint_ca("other").unwrap();
    let other = kit.dir.join("other.der");
    std::fs::write(&other, der).unwrap();
    let host = Host::new(HostConfig::new([other, kit.root.clone()])).unwrap();
    assert!(host.load(&p).is_ok());
}

#[test]
fn a_missing_blake3_pin_is_refused() {
    let kit = Kit::new("nopin");
    let p = kit.place("good", &component(&good_wat()));
    std::fs::remove_file(sidecar(&p, "blake3")).unwrap();
    assert!(matches!(
        kit.host().load(&p),
        Err(LoadRefusal::DigestMissing { .. })
    ));
}

#[test]
fn a_wrong_blake3_pin_is_refused_even_with_a_good_signature() {
    let kit = Kit::new("wrongpin");
    let p = kit.place("good", &component(&good_wat()));
    std::fs::write(
        sidecar(&p, "blake3"),
        blake3::hash(b"other").to_hex().as_str(),
    )
    .unwrap();
    assert!(matches!(
        kit.host().load(&p),
        Err(LoadRefusal::DigestMismatch { .. })
    ));
}

#[test]
fn a_blake3_pin_that_is_not_hex_is_refused() {
    let kit = Kit::new("junkpin");
    let p = kit.place("good", &component(&good_wat()));
    std::fs::write(sidecar(&p, "blake3"), "zz not hex").unwrap();
    assert!(matches!(
        kit.host().load(&p),
        Err(LoadRefusal::DigestMismatch { .. })
    ));
}

#[test]
fn an_uppercase_or_newline_terminated_pin_is_accepted() {
    let kit = Kit::new("pinforms");
    let bytes = component(&good_wat());
    let p = kit.place("good", &bytes);
    std::fs::write(
        sidecar(&p, "blake3"),
        format!("{}\n", blake3::hash(&bytes).to_hex().to_uppercase()),
    )
    .unwrap();
    assert!(kit.host().load(&p).is_ok());
}

#[test]
fn the_callers_pin_must_match_too() {
    let kit = Kit::new("callerpin");
    let bytes = component(&good_wat());
    let p = kit.place("good", &bytes);
    let host = kit.host();
    assert!(host
        .load_pinned(&p, blake3::hash(&bytes).to_hex().as_str())
        .is_ok());
    match host.load_pinned(&p, blake3::hash(b"x").to_hex().as_str()) {
        Err(LoadRefusal::DigestMismatch { pin, .. }) => assert_eq!(pin, "caller pin"),
        Err(e) => panic!("other refusal: {e}"),
        Ok(_) => panic!("a guest with the wrong caller pin loaded"),
    }
}

#[test]
fn a_module_over_the_size_cap_is_refused_before_it_is_read_through() {
    let kit = Kit::new("toolarge");
    let p = kit.place("good", &component(&good_wat()));
    let mut cfg = kit.config();
    cfg.max_module_bytes = 100;
    assert!(matches!(
        Host::new(cfg).unwrap().load(&p),
        Err(LoadRefusal::TooLarge { .. })
    ));
}

#[test]
fn a_missing_module_file_is_refused() {
    let kit = Kit::new("missing");
    assert!(matches!(
        kit.host().load(&kit.dir.join("nope.wasm")),
        Err(LoadRefusal::Unreadable { .. })
    ));
}

#[test]
fn a_load_refusal_reads_as_refused() {
    let kit = Kit::new("display");
    let e = kit.host().load(&kit.dir.join("nope.wasm")).err().unwrap();
    assert!(
        e.to_string().starts_with("refused: guest not loaded ("),
        "{e}"
    );
}